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 +
+ {{ t('components.main.form.labels.apiEndpoint') }} +
+ + +
+ + {{ t('components.main.form.hints.apiEndpoint') }} + +
+``` + +#### 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. 打开代理开关 -## 预览 -![亮色主界面](resources/images/code-switch.png) -![暗色主界面](resources/images/code-swtich-dark.png) -![日志亮色](resources/images/code-switch-logs.png) -![日志暗色](resources/images/code-switch-logs-dark.png) +在供应商列表上方,打开 **代理开关**(蓝色表示开启)。 -## 开发准备 -- 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. 如果供应商失败,自动尝试下一个 + +## 界面预览 + +| 亮色主题 | 暗色主题 | +|---------|---------| +| ![亮色主界面](resources/images/code-switch.png) | ![暗色主界面](resources/images/code-swtich-dark.png) | +| ![日志亮色](resources/images/code-switch-logs.png) | ![日志暗色](resources/images/code-switch-logs-dark.png) | + +## 常见问题 + +### 打开开关后 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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') }} +
+
+ + + + + + + + + +
+
+ +
+

{{ $t('components.general.title.dataImport') }}

+
+ + + + + + {{ $t('components.general.import.loading') }} + + + {{ $t('components.general.import.configFound') }} + + ({{ $t('components.general.import.pendingCount', { + providers: importStatus.pending_provider_count, + mcp: importStatus.pending_mcp_count + }) }}) + + + + {{ $t('components.general.import.configNotFound') }} + + + + +
@@ -126,6 +1012,152 @@ onMounted(() => { + +
+

{{ $t('components.general.title.update') }}

+
+ + + + + + {{ formatLastCheckTime(updateState?.last_check_time) }} + + ⚠️ {{ $t('components.general.update.checkFailed', { count: updateState.consecutive_failures }) }} + + + + + {{ appVersion }} + + + + {{ updateState.latest_known_version }} 🆕 + + + + + + + + + +
+
+ + 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 @@ + + + + + +
+ {{ t('components.main.form.labels.connectivityAuthType') }} + +
+ + + {{ authTypeOptions.find((item) => item.value === selectedAuthType)?.label || selectedAuthType }} + + + + + +
+ {{ option.label }} +
+
+
+
+
+ + {{ t('components.main.form.hints.connectivityAuthType') }} +
+
{{ t('components.main.form.labels.icon') }} - +
@@ -424,8 +667,18 @@ +
+ +
{{ iconName }}
+
+ {{ t('components.main.form.noIconResults') }} +
@@ -481,6 +737,17 @@ +
+ +
+
{{ t('components.main.form.labels.enabled') }}
@@ -494,6 +761,43 @@
+ +
+ {{ t('components.main.form.labels.availabilityMonitor') }} +
+ + + {{ modalState.form.availabilityMonitorEnabled ? t('components.main.form.switch.on') : t('components.main.form.switch.off') }} + +
+ {{ t('components.main.form.hints.availabilityMonitor') }} +
+ + +
+ {{ t('components.main.form.labels.connectivityAutoBlacklist') }} +
+ + + {{ modalState.form.connectivityAutoBlacklist ? t('components.main.form.switch.on') : t('components.main.form.switch.off') }} + +
+ {{ t('components.main.form.hints.connectivityAutoBlacklist') }} +
+ + +
+ + 💡 {{ t('components.main.form.hints.availabilityAdvancedConfig') }} + +
+
{{ t('components.main.form.actions.cancel') }} @@ -501,6 +805,15 @@ {{ t('components.main.form.actions.save') }} + + + {{ t('components.main.form.actions.saveAndApply') }} +
@@ -524,23 +837,171 @@ + + + +
+ + + +
+
+ {{ t('components.main.customCli.configFiles') }} + +
+
+
+
+ + + + +
+ +
+
+
+ + +
+
+ {{ t('components.main.customCli.proxySettings') }} + +
+
+
+
+ + +
+
+ + +
+
+
+

{{ t('components.main.customCli.proxyHint') }}

+
+ +
+ + {{ t('components.main.form.actions.cancel') }} + + + {{ t('components.main.form.actions.save') }} + +
+
+
+ + + +
+

{{ t('components.main.customCli.deleteMessage', { name: cliToolConfirmState.tool?.name ?? '' }) }}

+
+
+ + {{ t('components.main.form.actions.cancel') }} + + + {{ t('components.main.form.actions.delete') }} + +
+
- diff --git a/frontend/src/components/Mcp/BatchImportModal.vue b/frontend/src/components/Mcp/BatchImportModal.vue new file mode 100644 index 0000000..da49ba4 --- /dev/null +++ b/frontend/src/components/Mcp/BatchImportModal.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/src/components/Mcp/index.vue b/frontend/src/components/Mcp/index.vue index 681cdf2..c2832d0 100644 --- a/frontend/src/components/Mcp/index.vue +++ b/frontend/src/components/Mcp/index.vue @@ -82,6 +82,18 @@ /> + @@ -143,7 +155,15 @@ + + + + + + +
+ +
{{ formJsonSyncedText }}
+ +

{{ formJsonError }}

+

{{ t('components.mcp.form.jsonEditor.hint') }}

+
+ +

{{ modalError }}

-
+
{{ t('components.mcp.form.actions.cancel') }} {{ t('components.mcp.form.actions.save') }} -
+ - - + -
@@ -289,19 +378,33 @@ {{ t('components.mcp.form.actions.delete') }} - + + +
diff --git a/frontend/src/components/Prompts/Index.vue b/frontend/src/components/Prompts/Index.vue new file mode 100644 index 0000000..1cf1903 --- /dev/null +++ b/frontend/src/components/Prompts/Index.vue @@ -0,0 +1,653 @@ + + + + + diff --git a/frontend/src/components/Setting/NetworkWslSettings.vue b/frontend/src/components/Setting/NetworkWslSettings.vue new file mode 100644 index 0000000..17423f7 --- /dev/null +++ b/frontend/src/components/Setting/NetworkWslSettings.vue @@ -0,0 +1,602 @@ + + + + + diff --git a/frontend/src/components/Setting/ShortcutInput.vue b/frontend/src/components/Setting/ShortcutInput.vue index 9f93d98..446581c 100644 --- a/frontend/src/components/Setting/ShortcutInput.vue +++ b/frontend/src/components/Setting/ShortcutInput.vue @@ -188,6 +188,7 @@ const placeholderText = computed(() => t('components.shortcut.placeholder')) .mac-shortcut-input:focus-within { border-color: var(--mac-accent); + box-shadow: 0 0 0 3px rgba(10, 132, 255, 0.25); /* fallback for old WebKit */ box-shadow: 0 0 0 3px color-mix(in srgb, var(--mac-accent) 25%, transparent); } @@ -229,6 +230,7 @@ const placeholderText = computed(() => t('components.shortcut.placeholder')) } .mac-shortcut-clear:hover { + background: rgba(110, 110, 115, 0.15); /* fallback for old WebKit */ background: color-mix(in srgb, var(--mac-text-secondary) 15%, transparent); color: var(--mac-text); } diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue new file mode 100644 index 0000000..f6e0622 --- /dev/null +++ b/frontend/src/components/Sidebar.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/frontend/src/components/Skill/Index.vue b/frontend/src/components/Skill/Index.vue index 1fa10ac..ca2f96c 100644 --- a/frontend/src/components/Skill/Index.vue +++ b/frontend/src/components/Skill/Index.vue @@ -18,6 +18,13 @@ stroke-linejoin="round" /> + + +
{{ t('components.skill.list.loading') }}
-
{{ t('components.skill.list.empty') }}
-
-
-
+ + + +

{{ skillsError }}

+
+ + + + +
+

+ {{ t('components.skill.install.desc', { name: installTarget?.name }) }} +

+ +
+
+
+ + +
+
+
+ +

{{ t('components.skill.repos.subtitle') }}

@@ -157,8 +302,12 @@ import { useRouter } from 'vue-router' import { Browser } from '@wailsio/runtime' import { fetchSkills, + fetchSkillsForPlatform, installSkill, - uninstallSkill, + uninstallSkillEx, + toggleSkill, + getSkillContent, + openSkillFolder, fetchSkillRepos, addSkillRepo, removeSkillRepo, @@ -166,24 +315,60 @@ import { type SkillRepoConfig } from '../../services/skill' import BaseModal from '../common/BaseModal.vue' +import SkillCard from './SkillCard.vue' const router = useRouter() const { t } = useI18n() +// Platform definitions (use computed for i18n reactivity) +const platforms = computed(() => [ + { value: 'claude' as const, label: t('components.skill.platform.claude') }, + { value: 'codex' as const, label: t('components.skill.platform.codex') } +]) + +// State +const activePlatform = ref<'claude' | 'codex'>('claude') const skills = ref([]) const repoList = ref([]) -const repoModalOpen = ref(false) const loading = ref(false) const repoLoading = ref(false) const skillsError = ref('') const repoError = ref('') const processingSkill = ref('') +const togglingSkill = ref('') const repoBusy = ref(false) const repoForm = reactive({ url: '', branch: 'main' }) -const skillRepoUrl = 'https://github.com/ComposioHQ/awesome-claude-skills' +const repoModalOpen = ref(false) + +// Install modal state +const installModalOpen = ref(false) +const installTarget = ref(null) +const installLocation = ref<'user' | 'project'>('user') +const installing = ref(false) + +// Expanded skills +const expandedSkills = ref>(new Set()) const refreshing = computed(() => loading.value || repoLoading.value) +// Computed: Split skills by location +const installedSkills = computed(() => + skills.value.filter(s => s.installed) +) + +const projectSkills = computed(() => + skills.value.filter(s => s.install_location === 'project' && s.installed) +) + +const userSkills = computed(() => + skills.value.filter(s => s.install_location === 'user' && s.installed) +) + +const availableSkills = computed(() => + skills.value.filter(s => !s.installed) +) + +// Skill identity helpers const skillIdentity = (skill: SkillSummary) => skill.key || `${(skill.repo_owner ?? 'local').toLowerCase()}:${skill.directory.toLowerCase()}` @@ -191,22 +376,40 @@ const installProcessingKey = (skill: SkillSummary) => `install:${skillIdentity(s const uninstallProcessingKey = (skill: SkillSummary) => `uninstall:${skillIdentity(skill)}` const isInstallingSkill = (skill: SkillSummary) => processingSkill.value === installProcessingKey(skill) -const isUninstallingSkill = (skill: SkillSummary) => processingSkill.value === uninstallProcessingKey(skill) const canInstallSkill = (skill: SkillSummary) => Boolean(skill.repo_owner && skill.repo_name) -const updateSkillInstalledFlag = (skill: SkillSummary, installed: boolean) => { - const key = skillIdentity(skill) - const target = skills.value.find((item) => skillIdentity(item) === key) - if (target) { - target.installed = installed - } +// Platform switching +const switchPlatform = async (platform: 'claude' | 'codex') => { + activePlatform.value = platform + await loadSkillsForPlatform() } -const loadSkills = async () => { +// Load skills for current platform +const loadSkillsForPlatform = async () => { loading.value = true skillsError.value = '' try { - skills.value = await fetchSkills() + // Load installed skills for this platform (has correct install_location) + const installed = await fetchSkillsForPlatform(activePlatform.value) + // Also load available skills from repos + const available = await fetchSkills() + + // FIX: Only keep repo skills that can be installed (have repo info) + // Force installed=false to avoid "gap" where skills fall into neither group + const availableClean = available + .filter(s => s.repo_owner && s.repo_name) // Only installable repo skills + .map(s => ({ + ...s, + installed: false, // Force to false - actual status from fetchSkillsForPlatform + install_location: '' as const, + platform: '' as const + })) + + // Merge: installed skills take precedence by directory name + const installedDirs = new Set(installed.map(s => s.directory.toLowerCase())) + const filtered = availableClean.filter(s => !installedDirs.has(s.directory.toLowerCase())) + + skills.value = [...installed, ...filtered] } catch (error) { console.error('failed to load skills', error) skillsError.value = t('components.skill.list.error') @@ -230,20 +433,119 @@ const loadRepos = async () => { } const refresh = () => { - void Promise.all([loadRepos(), loadSkills()]) + void Promise.all([loadRepos(), loadSkillsForPlatform()]) } -const openRepoModal = () => { - repoModalOpen.value = true - if (!repoList.value.length && !repoLoading.value) { - void loadRepos() +// Toggle skill enabled status +const handleToggle = async (skill: SkillSummary, enabled: boolean) => { + togglingSkill.value = skill.key + try { + await toggleSkill( + skill.directory, + skill.platform || activePlatform.value, + skill.install_location || 'user', + enabled + ) + // Update local state + const target = skills.value.find(s => s.key === skill.key) + if (target) { + target.enabled = enabled + } + } catch (error) { + console.error('failed to toggle skill', error) + skillsError.value = t('components.skill.actions.toggleError') + } finally { + togglingSkill.value = '' } } -const closeRepoModal = () => { - repoModalOpen.value = false +// Toggle content expansion +const toggleExpand = async (skill: SkillSummary) => { + const key = skill.key + if (expandedSkills.value.has(key)) { + expandedSkills.value.delete(key) + } else { + expandedSkills.value.add(key) + } +} + +// Open skill folder (default: user location) +const handleOpenFolder = async () => { + try { + await openSkillFolder(activePlatform.value, 'user') + } catch (error) { + console.error('failed to open folder', error) + } +} + +// Open skill folder for specific location +const handleOpenFolderForLocation = async (location: 'user' | 'project') => { + try { + await openSkillFolder(activePlatform.value, location) + } catch (error) { + console.error('failed to open folder', error) + } } +// Install modal +const openInstallModal = (skill: SkillSummary) => { + installTarget.value = skill + installLocation.value = 'user' + installModalOpen.value = true +} + +const closeInstallModal = () => { + installModalOpen.value = false + installTarget.value = null +} + +const confirmInstall = async () => { + if (!installTarget.value || !canInstallSkill(installTarget.value)) return + + installing.value = true + processingSkill.value = installProcessingKey(installTarget.value) + + try { + await installSkill({ + directory: installTarget.value.directory, + repo_owner: installTarget.value.repo_owner, + repo_name: installTarget.value.repo_name, + repo_branch: installTarget.value.repo_branch, + platform: activePlatform.value, + location: installLocation.value + }) + skillsError.value = '' + closeInstallModal() + await loadSkillsForPlatform() + } catch (error) { + console.error('failed to install skill', error) + skillsError.value = t('components.skill.actions.installError', { name: installTarget.value.name }) + } finally { + installing.value = false + processingSkill.value = '' + } +} + +// Uninstall +const handleUninstall = async (skill: SkillSummary) => { + processingSkill.value = uninstallProcessingKey(skill) + try { + await uninstallSkillEx( + skill.directory, + skill.platform || activePlatform.value, + skill.install_location || 'user' + ) + skillsError.value = '' + await loadSkillsForPlatform() + } catch (error) { + console.error('failed to uninstall skill', error) + skillsError.value = t('components.skill.actions.uninstallError', { name: skill.name }) + } finally { + processingSkill.value = '' + } +} + +// Navigation const goHome = () => { router.push('/') } @@ -260,8 +562,16 @@ const openGithub = (url: string) => { openExternal(url) } -const openSkillRepo = () => { - openExternal(skillRepoUrl) +// Repository modal +const openRepoModal = () => { + repoModalOpen.value = true + if (!repoList.value.length && !repoLoading.value) { + void loadRepos() + } +} + +const closeRepoModal = () => { + repoModalOpen.value = false } const repoKey = (repo: SkillRepoConfig) => `${repo.owner}/${repo.name}` @@ -296,7 +606,7 @@ const submitRepo = async () => { }) repoForm.url = '' repoForm.branch = 'main' - await loadSkills() + await loadSkillsForPlatform() } catch (error) { console.error('failed to add skill repo', error) repoError.value = t('components.skill.repos.addError') @@ -310,7 +620,7 @@ const removeRepo = async (repo: SkillRepoConfig) => { repoError.value = '' try { repoList.value = await removeSkillRepo(repo.owner, repo.name) - await loadSkills() + await loadSkillsForPlatform() } catch (error) { console.error('failed to remove skill repo', error) repoError.value = t('components.skill.repos.removeError') @@ -320,52 +630,12 @@ const removeRepo = async (repo: SkillRepoConfig) => { } const openRepoGithub = (repo: SkillRepoConfig) => { - if (!repo?.owner || !repo?.name) { - return - } - const url = `https://github.com/${repo.owner}/${repo.name}` - openExternal(url) -} - -const handleInstall = async (skill: SkillSummary) => { - if (!canInstallSkill(skill)) { - skillsError.value = t('components.skill.list.missingRepo') - return - } - processingSkill.value = installProcessingKey(skill) - try { - await installSkill({ - directory: skill.directory, - repo_owner: skill.repo_owner, - repo_name: skill.repo_name, - repo_branch: skill.repo_branch - }) - updateSkillInstalledFlag(skill, true) - skillsError.value = '' - } catch (error) { - console.error('failed to install skill', error) - skillsError.value = t('components.skill.actions.installError', { name: skill.name }) - } finally { - processingSkill.value = '' - } -} - -const handleUninstall = async (skill: SkillSummary) => { - processingSkill.value = uninstallProcessingKey(skill) - try { - await uninstallSkill(skill.directory) - updateSkillInstalledFlag(skill, false) - skillsError.value = '' - } catch (error) { - console.error('failed to uninstall skill', error) - skillsError.value = t('components.skill.actions.uninstallError', { name: skill.name }) - } finally { - processingSkill.value = '' - } + if (!repo?.owner || !repo?.name) return + openExternal(`https://github.com/${repo.owner}/${repo.name}`) } onMounted(() => { - void Promise.all([loadRepos(), loadSkills()]) + void Promise.all([loadRepos(), loadSkillsForPlatform()]) }) @@ -375,32 +645,257 @@ onMounted(() => { color: var(--mac-text); } -.skill-repo-section { +/* Platform Tabs */ +.skill-platform-tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; + border-bottom: 1px solid var(--mac-border); + padding-bottom: 12px; +} + +.skill-platform-tab { + padding: 8px 16px; border: 1px solid var(--mac-border); - border-radius: 20px; - padding: 20px; + border-radius: 8px; + background: transparent; + color: var(--mac-text-secondary); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.skill-platform-tab:hover { + background: var(--mac-surface); + color: var(--mac-text); +} + +.skill-platform-tab.active { + background: var(--mac-accent); + color: white; + border-color: var(--mac-accent); +} + +/* Skill Groups */ +.skill-group { + margin-bottom: 32px; +} + +.skill-group-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.skill-group-title { + font-size: 1rem; + font-weight: 600; + margin: 0; + color: var(--mac-text-secondary); + display: flex; + align-items: center; + gap: 8px; +} + +.skill-group-title::before { + content: ''; + display: inline-block; + width: 4px; + height: 16px; + background: var(--mac-accent); + border-radius: 2px; +} + +.skill-empty-installed { + text-align: center; + color: var(--mac-text-secondary); + padding: 24px; + margin-bottom: 24px; +} + +/* Skill List */ +.skill-list { display: flex; flex-direction: column; + gap: 12px; +} + +.installed-skills { gap: 16px; +} + +/* Available Skill Card */ +.skill-card.available-card { + background: var(--mac-surface-strong); /* fallback for old WebKit */ background: color-mix(in srgb, var(--mac-surface) 90%, transparent); + border: 1px solid var(--mac-border); + border-radius: 16px; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 8px; } -.skill-repo-header { +.skill-card-head { display: flex; - flex-wrap: wrap; - gap: 12px; justify-content: space-between; - align-items: center; + align-items: flex-start; + gap: 12px; } -.skill-repo-header h2 { +.skill-card-eyebrow { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--mac-text-secondary); + margin-bottom: 4px; +} + +.skill-card h3 { + font-size: 0.95rem; margin: 0; - font-size: 1.05rem; } -.skill-repo-header p { - margin: 4px 0 0; +.skill-card-desc { + color: var(--mac-text-secondary); + font-size: 0.85rem; + line-height: 1.4; +} + +.skill-card-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +/* Install Modal */ +.install-modal-content { + min-width: min(400px, 80vw); + display: flex; + flex-direction: column; + gap: 20px; +} + +.install-modal-desc { color: var(--mac-text-secondary); + font-size: 0.95rem; +} + +.install-location-options { + display: flex; + flex-direction: column; + gap: 12px; +} + +.install-option { + display: block; + cursor: pointer; +} + +.install-option-content { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 16px; + border: 2px solid var(--mac-border); + border-radius: 12px; + transition: all 0.2s ease; +} + +.install-option:hover .install-option-content { + border-color: var(--mac-accent); +} + +.install-option.selected .install-option-content { + border-color: var(--mac-accent); + background: rgba(10, 132, 255, 0.1); /* fallback for old WebKit */ + background: color-mix(in srgb, var(--mac-accent) 10%, transparent); +} + +.install-option-icon { + width: 24px; + height: 24px; + flex-shrink: 0; + color: var(--mac-text-secondary); +} + +.install-option.selected .install-option-icon { + color: var(--mac-accent); +} + +.install-option-title { + font-weight: 600; + margin: 0 0 4px; +} + +.install-option-desc { + font-size: 0.85rem; + color: var(--mac-text-secondary); + margin: 0; + font-family: monospace; +} + +.install-option-warning { + font-size: 0.8rem; + color: #f59e0b; + margin: 8px 0 0; +} + +.install-modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--mac-border); +} + +.btn-primary, +.btn-secondary { + padding: 8px 20px; + border-radius: 8px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--mac-accent); + color: white; + border: none; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + background: transparent; + color: var(--mac-text); + border: 1px solid var(--mac-border); +} + +.btn-secondary:hover { + background: var(--mac-surface); +} + +/* Repository Section (reused from original) */ +.skill-repo-section { + border: 1px solid var(--mac-border); + border-radius: 20px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + background: var(--mac-surface-strong); /* fallback for old WebKit */ + background: color-mix(in srgb, var(--mac-surface) 90%, transparent); } .skill-repo-subtitle { @@ -446,12 +941,6 @@ onMounted(() => { width: 160px; } -.skill-repo-actions { - display: flex; - gap: 8px; - align-items: center; -} - .skill-repo-list { display: flex; flex-direction: column; @@ -469,6 +958,7 @@ onMounted(() => { padding: 12px 18px; border: 1px solid var(--mac-border); border-radius: 12px; + background: var(--mac-surface-strong); /* fallback for old WebKit */ background: color-mix(in srgb, var(--mac-surface) 80%, transparent); gap: 16px; margin: 0 0 8px; @@ -494,11 +984,17 @@ onMounted(() => { color: var(--mac-text-secondary); } +.skill-repo-actions { + display: flex; + gap: 8px; + align-items: center; +} + .repo-modal-content { min-width: min(600px, 80vw); } - +/* Common */ .skill-hero { margin: 12px 0 12px; } @@ -509,72 +1005,15 @@ onMounted(() => { line-height: 1.5; } -.skill-link { - color: var(--mac-accent); - font-weight: 600; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0; - margin-left: 4px; -} - -.skill-link:focus-visible { - outline: none; - text-decoration: underline; -} - -.skill-link svg { - width: 16px; - height: 16px; -} - -.skill-link:hover { - text-decoration: underline; -} - .skill-hero h1 { font-size: clamp(26px, 3vw, 34px); margin-bottom: 8px; } -.skill-button { - border: none; - border-radius: 999px; - padding: 8px 20px; - font-weight: 600; - font-size: 0.95rem; - cursor: pointer; - background: #2563eb; - color: white; - transition: opacity 0.2s ease; -} - -.ghost-icon svg.spin { - animation: skill-spin 1s linear infinite; -} - -.skill-button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.skill-button.ghost { - background: transparent; - border: 1px solid rgba(148, 163, 184, 0.4); - color: #e2e8f0; -} - -.skill-button.danger { - background: #dc2626; -} - .skill-list-section { margin-top: 16px; } - .skill-empty { margin-top: 32px; color: var(--mac-text-secondary); @@ -585,80 +1024,13 @@ onMounted(() => { margin-top: 0; } -.skill-list { - margin-top: 8px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 24px; -} - -.skill-card { - background: color-mix(in srgb, var(--mac-surface) 90%, transparent); - border: 1px solid var(--mac-border); - border-radius: 24px; - padding: 24px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.skill-card-head { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; -} - -.skill-card-eyebrow { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.18em; - color: var(--mac-text-secondary); - margin-bottom: 4px; -} - -.skill-card h3 { - font-size: 1rem; - margin: 0 0 4px; -} - -.skill-card-desc { - color: var(--mac-text-secondary); - min-height: 50px; - font-size: 0.9rem; - line-height: 1.5; - margin-top: 8px; -} - - -.skill-card-actions { - display: flex; - gap: 6px; - flex-wrap: nowrap; -} - -.skill-card-actions .ghost-icon { - width: 32px; - height: 32px; -} - -.skill-card-actions .ghost-icon svg { - width: 18px; - height: 18px; -} - -.skill-card-actions .ghost-icon.danger { - color: #ef4444; -} - -.skill-card-actions .ghost-icon:disabled { - opacity: 0.5; - cursor: not-allowed; +.skill-error { + color: #f87171; + margin-top: 16px; } -.ghost-icon:disabled { - opacity: 0.5; - cursor: not-allowed; +.ghost-icon svg.spin { + animation: skill-spin 1s linear infinite; } .skill-action-spinner { @@ -671,30 +1043,30 @@ onMounted(() => { display: inline-block; } -.skill-error { - color: #f87171; - margin-top: 16px; -} - -.skill-page :where(button, h1, h2, h3, p) { - transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease; -} - -html.dark .skill-card { - background: color-mix(in srgb, var(--mac-surface) 70%, transparent); +.ghost-icon:disabled { + opacity: 0.5; + cursor: not-allowed; } -html.dark .skill-button.ghost { - border-color: rgba(255, 255, 255, 0.2); - color: var(--mac-text); +.ghost-icon.danger { + color: #ef4444; } -html.dark .skill-card-desc { - color: rgba(248, 250, 252, 0.8); +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; } -html.dark .skill-card-eyebrow { - color: rgba(248, 250, 252, 0.6); +html.dark .skill-card.available-card { + background: var(--mac-surface); /* fallback for old WebKit */ + background: color-mix(in srgb, var(--mac-surface) 70%, transparent); } @media (max-width: 768px) { @@ -702,22 +1074,15 @@ html.dark .skill-card-eyebrow { flex-direction: column; } - .skill-card { - padding: 20px; - } - - .skill-button { - flex: 1; - text-align: center; + .skill-platform-tabs { + flex-wrap: wrap; } - } @keyframes skill-spin { from { transform: rotate(0deg); } - to { transform: rotate(360deg); } diff --git a/frontend/src/components/Skill/SkillCard.vue b/frontend/src/components/Skill/SkillCard.vue new file mode 100644 index 0000000..4f31ef1 --- /dev/null +++ b/frontend/src/components/Skill/SkillCard.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/frontend/src/components/SpeedTest/Index.vue b/frontend/src/components/SpeedTest/Index.vue new file mode 100644 index 0000000..25e28de --- /dev/null +++ b/frontend/src/components/SpeedTest/Index.vue @@ -0,0 +1,662 @@ + + + + + diff --git a/frontend/src/components/Tray/Index.vue b/frontend/src/components/Tray/Index.vue new file mode 100644 index 0000000..77ac49c --- /dev/null +++ b/frontend/src/components/Tray/Index.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/frontend/src/components/common/BaseTextarea.vue b/frontend/src/components/common/BaseTextarea.vue index d8ab524..bb0389f 100644 --- a/frontend/src/components/common/BaseTextarea.vue +++ b/frontend/src/components/common/BaseTextarea.vue @@ -1,5 +1,6 @@