mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a520b52d8f | |||
| eaf83f4fbe | |||
| 90812e7f3a | |||
| 8c42ec48ef | |||
| 761038f0ea | |||
| 155a226e6a | |||
| 10be929f33 | |||
| 141010332b | |||
| 15989effb4 | |||
| d4edf30747 | |||
| 44b6eacf87 | |||
| 0a301a497c | |||
| 8aa6ec784b | |||
| c718dd703b | |||
| bd3cfb7741 | |||
| 117dbf1386 | |||
| 72f570b99e | |||
| 2296c41497 | |||
| fe3f9b60de | |||
| 3e78fe8305 | |||
| 6f170305b8 | |||
| 552f7abee4 | |||
| fd2b232f1c | |||
| 8c8b265ebf | |||
| 82c75de51c | |||
| 8a26a091a9 | |||
| c2120dc8c3 | |||
| 69ab4a8a46 | |||
| cf945de997 | |||
| 8ccfbd36d6 | |||
| eeda9adb03 | |||
| 36bbdc36f5 | |||
| fc08a5d364 | |||
| 333c9f277b | |||
| 9336001746 | |||
| 04254d6ffe | |||
| 28afbea917 | |||
| 81897ac17e | |||
| bb23ab918b | |||
| f38facd430 | |||
| 5c03de53f7 | |||
| d8a7bc32db | |||
| f1cad25777 | |||
| 9439153f05 | |||
| 7097a0d710 | |||
| f1d2c6045b | |||
| 9e5a3b2dc9 | |||
| 2466873db3 | |||
| 3c902b4599 |
+253
-23
@@ -150,9 +150,76 @@ jobs:
|
||||
fi
|
||||
echo "✅ Tauri signing key prepared"
|
||||
|
||||
- name: Import Apple signing certificate
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Decode .p12 certificate from base64
|
||||
CERT_PATH="$RUNNER_TEMP/certificate.p12"
|
||||
printf '%s' "${{ secrets.APPLE_CERTIFICATE }}" | (base64 --decode 2>/dev/null || base64 -D) > "$CERT_PATH"
|
||||
|
||||
# Save original default keychain for cleanup
|
||||
ORIGINAL_DEFAULT_KEYCHAIN=$(security default-keychain -d user | tr -d '"' | xargs)
|
||||
echo "ORIGINAL_DEFAULT_KEYCHAIN=$ORIGINAL_DEFAULT_KEYCHAIN" >> "$GITHUB_ENV"
|
||||
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
|
||||
security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security default-keychain -s "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
|
||||
|
||||
# Import certificate
|
||||
security import "$CERT_PATH" \
|
||||
-k "$KEYCHAIN_PATH" \
|
||||
-P "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
|
||||
|
||||
# Dynamically resolve signing identity (must be "Developer ID Application")
|
||||
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
|
||||
| grep "Developer ID Application" | grep -oE '"[^"]+"' | head -1 | tr -d '"')
|
||||
if [ -z "$IDENTITY" ]; then
|
||||
echo "❌ No 'Developer ID Application' identity found — listing all identities:" >&2
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Signing identity: $IDENTITY"
|
||||
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
|
||||
|
||||
# Cleanup certificate file
|
||||
rm -f "$CERT_PATH"
|
||||
|
||||
- name: Build Tauri App (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm tauri build --target universal-apple-darwin
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
max_attempts=3
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
echo "=== macOS build/notarization attempt ${attempt}/${max_attempts} ==="
|
||||
if pnpm tauri build --target universal-apple-darwin; then
|
||||
echo "✅ macOS build/notarization succeeded"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
echo "❌ macOS build/notarization failed after ${max_attempts} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep_seconds=$((attempt * 60))
|
||||
echo "⚠️ macOS build/notarization failed, retrying in ${sleep_seconds}s..."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
- name: Build Tauri App (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -169,7 +236,8 @@ jobs:
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||
|
||||
# Locate bundle artifacts
|
||||
TAR_GZ=""; APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
@@ -177,28 +245,150 @@ jobs:
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$TAR_GZ" ]; then
|
||||
echo "No macOS .tar.gz updater artifact found" >&2
|
||||
echo "❌ No macOS .tar.gz updater artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 重命名 tar.gz 为统一格式
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "❌ No .app found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Staple notarization ticket to .app (Tauri already notarized it)
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
echo "✅ .app stapled"
|
||||
|
||||
# 1) Collect .tar.gz (updater artifact)
|
||||
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
|
||||
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
|
||||
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
|
||||
echo "macOS updater artifact copied: $NEW_TAR_GZ"
|
||||
if [ -n "$APP_PATH" ]; then
|
||||
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
|
||||
cd "$APP_DIR"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
|
||||
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready: $NEW_ZIP"
|
||||
|
||||
# 2) Collect .app as zip
|
||||
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "release-assets/$NEW_ZIP"
|
||||
echo "macOS zip ready: $NEW_ZIP"
|
||||
|
||||
# 3) Create styled DMG with create-dmg (Tauri's built-in DMG styling doesn't work on CI)
|
||||
if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||
echo "❌ APPLE_SIGNING_IDENTITY is missing before DMG creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 brew install create-dmg
|
||||
NEW_DMG="CC-Switch-${VERSION}-macOS.dmg"
|
||||
DMG_STAGE_DIR="$RUNNER_TEMP/dmg-stage"
|
||||
rm -rf "$DMG_STAGE_DIR"
|
||||
mkdir -p "$DMG_STAGE_DIR"
|
||||
ditto "$APP_PATH" "$DMG_STAGE_DIR/CC Switch.app"
|
||||
|
||||
create-dmg \
|
||||
--volname "CC Switch" \
|
||||
--background "src-tauri/icons/dmg-background.png" \
|
||||
--window-size 660 400 \
|
||||
--window-pos 200 120 \
|
||||
--icon-size 80 \
|
||||
--icon "CC Switch.app" 180 220 \
|
||||
--hide-extension "CC Switch.app" \
|
||||
--app-drop-link 480 220 \
|
||||
--codesign "$APPLE_SIGNING_IDENTITY" \
|
||||
--no-internet-enable \
|
||||
"release-assets/$NEW_DMG" \
|
||||
"$DMG_STAGE_DIR"
|
||||
|
||||
rm -rf "$DMG_STAGE_DIR"
|
||||
echo "✅ Styled DMG created: $NEW_DMG"
|
||||
|
||||
- name: Notarize macOS DMG
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
|
||||
if [ -z "$DMG_PATH" ]; then
|
||||
echo "❌ No .dmg found in release-assets/ to notarize" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Notarizing DMG: $DMG_PATH ==="
|
||||
max_attempts=3
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
echo "=== DMG notarization attempt ${attempt}/${max_attempts} ==="
|
||||
if xcrun notarytool submit "$DMG_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait; then
|
||||
echo "✅ DMG notarization succeeded"
|
||||
xcrun stapler staple "$DMG_PATH"
|
||||
echo "✅ DMG stapled"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
echo "❌ DMG notarization failed after ${max_attempts} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep_seconds=$((attempt * 60))
|
||||
echo "⚠️ DMG notarization failed, retrying in ${sleep_seconds}s..."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
- name: Verify macOS code signing and notarization
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Verify .app (from Tauri bundle)
|
||||
APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "❌ No .app found for verification" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Verifying .app: $APP_PATH ==="
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
echo "✅ codesign verification passed"
|
||||
spctl -a -t exec -vv "$APP_PATH"
|
||||
echo "✅ spctl assessment passed"
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
echo "✅ .app stapler validation passed"
|
||||
|
||||
# Verify .dmg (from release-assets/, created by create-dmg + notarized)
|
||||
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
|
||||
if [ -n "$DMG_PATH" ]; then
|
||||
echo "=== Verifying .dmg: $DMG_PATH ==="
|
||||
codesign --verify --verbose=2 "$DMG_PATH"
|
||||
echo "✅ .dmg codesign verification passed"
|
||||
spctl -a -t open --context context:primary-signature -vv "$DMG_PATH"
|
||||
echo "✅ .dmg spctl assessment passed"
|
||||
xcrun stapler validate "$DMG_PATH"
|
||||
echo "✅ .dmg stapler validation passed"
|
||||
else
|
||||
echo "No .app found to zip (optional)" >&2
|
||||
echo "❌ No .dmg found for verification — release would ship without verified DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Prepare Windows Assets
|
||||
@@ -299,6 +489,51 @@ jobs:
|
||||
echo "Collected signatures (if any alongside artifacts):"
|
||||
ls -la release-assets/*.sig || echo "No signatures found"
|
||||
|
||||
- name: Upload release artifacts to workflow
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-assets-${{ runner.os }}-${{ matrix.arch || runner.arch }}
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: List generated bundles (debug)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||
|
||||
- name: Clean up Apple signing keychain
|
||||
if: runner.os == 'macOS' && always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${ORIGINAL_DEFAULT_KEYCHAIN:-}" ]; then
|
||||
security default-keychain -s "$ORIGINAL_DEFAULT_KEYCHAIN" || true
|
||||
fi
|
||||
if [ -f "$RUNNER_TEMP/build.keychain-db" ]; then
|
||||
security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true
|
||||
fi
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download built release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-assets-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -la release-assets
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
@@ -312,28 +547,23 @@ jobs:
|
||||
|
||||
### 下载
|
||||
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.dmg`(推荐)或 `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)
|
||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
> `.tar.gz` 为 Tauri updater 自动更新专用,无需手动下载。
|
||||
|
||||
---
|
||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
files: release-assets/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: List generated bundles (debug)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||
|
||||
assemble-latest-json:
|
||||
name: Assemble latest.json
|
||||
runs-on: ubuntu-22.04
|
||||
needs: release
|
||||
needs: publish-release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
+5
-1
@@ -24,4 +24,8 @@ flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
.worktrees/
|
||||
.spec-workflow/
|
||||
.spec-workflow/
|
||||
copilot-api
|
||||
.history
|
||||
CODEBUDDY.md
|
||||
.github
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22.12.0
|
||||
22.12.0
|
||||
|
||||
@@ -5,6 +5,65 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.12.3] - 2026-03-24
|
||||
|
||||
Major release adding GitHub Copilot reverse proxy support, macOS code signing & Apple notarization, intelligent reasoning effort mapping for o-series models, skill backup/restore lifecycle, proxy gzip compression, and critical fixes for WebDAV password safety, tool message parsing, and dark mode.
|
||||
|
||||
**Stats**: 36 commits | 107 files changed | +9,124 insertions | -802 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **GitHub Copilot Reverse Proxy**: Full GitHub Copilot integration as a Claude Code provider via OAuth Device Code flow; includes multi-account management, automatic token refresh, Anthropic ↔ OpenAI format conversion, real-time model list fetching, and usage statistics (#930)
|
||||
- **Copilot Auth Center**: New Auth Center panel in Settings for managing GitHub accounts globally, with per-provider account binding via `meta.authBinding`
|
||||
- **Tool Search Toggle**: Added `ENABLE_TOOL_SEARCH` env var support for Claude 2.1.76+; exposed as a checkbox in the provider Common Config editor (#930)
|
||||
- **Reasoning Effort Mapping**: Two-tier `resolve_reasoning_effort()` for OpenAI o-series and GPT-5+ models — explicit `output_config.effort` takes priority, falling back to thinking `budget_tokens` thresholds (<4 000→low, 4 000–16 000→medium, ≥16 000→high); covers both Chat Completions and Responses API paths with 17 unit tests
|
||||
- **OpenCode SQLite Backend**: Added SQLite session storage support for OpenCode alongside existing JSON backend; dual-backend scan with SQLite priority on ID conflicts, atomic session deletion, and path validation (#1401)
|
||||
- **Skill Auto-Backup**: Skill files are automatically backed up to `~/.cc-switch/skill-backups/` before uninstall, with metadata preserved in `meta.json`; old backups pruned to keep at most 20
|
||||
- **Skill Backup Restore & Delete**: Added list/restore/delete commands for skill backups; restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure
|
||||
- **macOS Code Signing & Notarization**: CI now imports an Apple Developer ID certificate, signs the universal binary, submits for Apple notarization, and staples the ticket to both `.app` and `.dmg`; a hard-fail verification step (`codesign --verify` + `spctl -a` + `stapler validate`) gates the release for both artifacts
|
||||
- **Codex 1M Context Window Toggle**: One-click checkbox in Codex config editor to set `model_context_window = 1000000` with auto-populated `model_auto_compact_token_limit = 900000`; unchecking removes both fields
|
||||
- **Disable Auto-Upgrade Toggle**: Added `DISABLE_AUTOUPDATER` env var checkbox in the Claude Common Config editor to prevent Claude Code from auto-upgrading
|
||||
|
||||
### Changed
|
||||
|
||||
- **Skills Cache Strategy**: Replaced `invalidateQueries` with direct `setQueryData` updates for skill install/uninstall/import operations; added `staleTime: Infinity` with `keepPreviousData` to eliminate loading flicker (#1573)
|
||||
- **Proxy Gzip Compression**: Non-streaming proxy requests now auto-negotiate gzip compression instead of forcing `identity`; streaming requests conservatively keep `identity` to avoid SSE decompression errors
|
||||
- **o1/o3 Model Compatibility**: Chat Completions proxy forwarding now correctly uses `max_completion_tokens` instead of `max_tokens` for OpenAI o-series models such as o1/o3/o4-mini (#1451)
|
||||
- **OpenCode Model Variants**: Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
|
||||
- **Skills Import Flow**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation; added reconciliation to remove disabled/orphaned symlinks and MCP servers from live config
|
||||
- **Claude 4.6 Context Window**: Updated Claude Opus 4.6 and Sonnet 4.6 context window from 200K to 1M across OpenClaw and OpenCode presets (GA release)
|
||||
- **MiniMax Model Upgrade**: Updated MiniMax presets from M2.5 to M2.7 across Claude, OpenClaw, and OpenCode configurations with updated partner descriptions in all three locales
|
||||
- **Xiaomi MiMo Model Upgrade**: Updated MiMo presets from mimo-v2-flash to mimo-v2-pro across all supported applications
|
||||
- **AddProviderDialog Simplification**: Removed redundant OAuth tab, reducing dialog from 3 tabs to 2 (app-specific + universal)
|
||||
- **Provider Form Advanced Options Collapse**: Model mapping, API format, and other advanced fields in the Claude provider form now auto-collapse when empty; auto-expands when any value is set or when a preset fills them in
|
||||
|
||||
### Fixed
|
||||
|
||||
- **WebDAV Password Silent Clear**: Fixed WebDAV password being silently wiped when ProviderList or UsageScriptModal saved settings by stripping `webdavSync` from frontend payloads and adding backend backfill logic in `merge_settings_for_save()` to preserve existing passwords
|
||||
- **Tool Message Parsing**: Fixed tool_use/tool_result message classification across Claude (tool_result content blocks), Codex (function_call/function_call_output payloads), and Gemini (array content + toolCalls extraction) session providers (#1401)
|
||||
- **Dark Mode Selector**: Changed Tailwind `darkMode` from `["selector", "class"]` to `["selector", ".dark"]` to ensure correct dark mode activation (#1596)
|
||||
- **Copilot Request Fingerprint**: Unified Copilot request fingerprint headers across all API call sites to prevent User-Agent leakage and stream check mismatches
|
||||
- **o-series Responses API Tokens**: Kept Responses API on the correct `max_output_tokens` field for o-series models instead of incorrectly injecting `max_completion_tokens`
|
||||
- **Provider Form Double Submit**: Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)
|
||||
- **Ghostty Session Restore**: Fixed Claude session restore in Ghostty terminal (#1506)
|
||||
- **Skill ZIP Import Extension**: Added `.skill` file extension support in ZIP import dialog (#1240, #1455)
|
||||
- **Skill ZIP Install Target App**: ZIP skill installs now use the currently active app instead of always defaulting to Claude
|
||||
- **OpenClaw Active Card Highlight**: Fixed active OpenClaw provider card not being highlighted (#1419)
|
||||
- **Responsive Layout with TOC**: Improved responsive design when TOC title exists (#1491)
|
||||
- **Import Skills Dialog White Screen**: Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog
|
||||
- **Panel Bottom Blank Area**: Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values
|
||||
|
||||
### Docs
|
||||
|
||||
- **Pricing Model ID Normalization**: Added documentation section explaining model ID normalization rules (prefix stripping, suffix trimming, `@`→`-` replacement) in EN/ZH/JA user manuals (#1591)
|
||||
- **macOS Signed & Notarized**: Removed all `xattr` workaround instructions and "unidentified developer" warnings from README, README_ZH, installation guides (EN/ZH/JA), and FAQ pages (EN/ZH/JA); replaced with "signed and notarized by Apple" messaging
|
||||
|
||||
---
|
||||
|
||||
## [3.12.2] - 2026-03-12
|
||||
|
||||
Post-v3.12.1 work focuses on Common Config safety during proxy takeover and more reliable Codex TOML editing.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -22,9 +22,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
|
||||
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
|
||||
MiniMax-M2.7 is a next-generation large language model designed for autonomous evolution and real-world productivity. Unlike traditional models, M2.7 actively participates in its own improvement through agent teams, dynamic tool use, and reinforcement learning loops. It delivers strong performance in software engineering (56.22% on SWE-Pro, 55.6% on VIBE-Pro, 57.0% on Terminal Bench 2) and excels in complex office workflows, achieving a leading 1495 ELO on GDPval-AA. With high-fidelity editing across Word, Excel, and PowerPoint, and a 97% adherence rate across 40+ complex skills, M2.7 sets a new standard for building AI-native workflows and organizations.
|
||||
|
||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Token Plan!
|
||||
|
||||
---
|
||||
|
||||
@@ -126,7 +126,7 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
|
||||
|
||||
## Features
|
||||
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.1-en.md)
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.3-en.md)
|
||||
|
||||
### Provider Management
|
||||
|
||||
@@ -184,9 +184,9 @@ CC Switch provides a "Shared Config Snippet" feature to pass common data (beyond
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS shows "unidentified developer" warning — how do I fix it?</strong></summary>
|
||||
<summary><strong>macOS installation</strong></summary>
|
||||
|
||||
The author doesn't have an Apple Developer account yet (registration in progress). Close the warning, then go to **System Settings → Privacy & Security → Open Anyway**. After that, the app will open normally.
|
||||
CC Switch for macOS is code-signed and notarized by Apple. You can download and install it directly — no extra steps needed. We recommend using the `.dmg` installer.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -211,6 +211,7 @@ Add an official provider from the preset list. After switching to it, run the Lo
|
||||
- **Local settings**: `~/.cc-switch/settings.json` (device-level UI preferences)
|
||||
- **Backups**: `~/.cc-switch/backups/` (auto-rotated, keeps 10 most recent)
|
||||
- **Skills**: `~/.cc-switch/skills/` (symlinked to corresponding apps by default)
|
||||
- **Skill Backups**: `~/.cc-switch/skill-backups/` (created automatically before uninstall, keeps 20 most recent)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -243,7 +244,7 @@ For detailed guides on every feature, check out the **[User Manual](docs/user-ma
|
||||
### System Requirements
|
||||
|
||||
- **Windows**: Windows 10 and above
|
||||
- **macOS**: macOS 10.15 (Catalina) and above
|
||||
- **macOS**: macOS 12 (Monterey) and above
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions
|
||||
|
||||
### Windows Users
|
||||
@@ -267,9 +268,9 @@ brew upgrade --cask cc-switch
|
||||
|
||||
**Method 2: Manual Download**
|
||||
|
||||
Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.
|
||||
Download `CC-Switch-v{version}-macOS.dmg` (recommended) or `.zip` from the [Releases](../../releases) page.
|
||||
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
||||
> **Note**: CC Switch for macOS is code-signed and notarized by Apple. You can install and open it directly.
|
||||
|
||||
### Arch Linux Users
|
||||
|
||||
|
||||
+6
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -22,9 +22,9 @@
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
|
||||
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。
|
||||
MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設計された次世代大規模言語モデルです。従来のモデルとは異なり、M2.7 はエージェントチーム、動的ツール使用、強化学習ループを通じて自身の改善に積極的に参加します。ソフトウェアエンジニアリングにおいて優れた性能を発揮し(SWE-Pro で 56.22%、VIBE-Pro で 55.6%、Terminal Bench 2 で 57.0%)、複雑なオフィスワークフローにも秀でており、GDPval-AA で 1495 ELO のリーディングスコアを達成しています。Word・Excel・PowerPoint の高忠実度編集と、40 以上の複雑なスキルにわたる 97% の遵守率により、M2.7 は AI ネイティブなワークフローと組織構築の新基準を打ち立てます。
|
||||
|
||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手!
|
||||
|
||||
---
|
||||
|
||||
@@ -126,7 +126,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
## 特長
|
||||
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.1-ja.md)
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.3-ja.md)
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
@@ -211,6 +211,7 @@ CC Switch は「最小限の介入」という設計原則に従っています
|
||||
- **ローカル設定**: `~/.cc-switch/settings.json`(デバイスレベルの UI 設定)
|
||||
- **バックアップ**: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
|
||||
- **Skills**: `~/.cc-switch/skills/`(デフォルトでシンボリックリンクにより対応アプリに接続)
|
||||
- **Skill バックアップ**: `~/.cc-switch/skill-backups/`(アンインストール前に自動作成、最新 20 件を保持)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -243,7 +244,7 @@ CC Switch は「最小限の介入」という設計原則に従っています
|
||||
### システム要件
|
||||
|
||||
- **Windows**: Windows 10 以上
|
||||
- **macOS**: macOS 10.15 (Catalina) 以上
|
||||
- **macOS**: macOS 12 (Monterey) 以上
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション
|
||||
|
||||
### Windows ユーザー
|
||||
|
||||
+10
-9
@@ -4,7 +4,7 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -22,9 +22,9 @@
|
||||
|
||||
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
||||
|
||||
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来。
|
||||
MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力。
|
||||
|
||||
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
|
||||
[点击此处](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)享 MiniMax Token Plan 专属 88 折优惠!
|
||||
|
||||
---
|
||||
|
||||
@@ -127,7 +127,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
## 功能特性
|
||||
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.1-zh.md)
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.3-zh.md)
|
||||
|
||||
### 供应商管理
|
||||
|
||||
@@ -185,9 +185,9 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS 提示"未知开发者"警告 — 如何解决?</strong></summary>
|
||||
<summary><strong>macOS 安装</strong></summary>
|
||||
|
||||
这是由于作者没有苹果开发者账号(正在注册中)。关闭警告后,前往**系统设置 → 隐私与安全性 → 仍要打开**。之后应用即可正常打开。
|
||||
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安装,无需额外操作。推荐使用 `.dmg` 安装包。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -214,6 +214,7 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
|
||||
- **本地设置**:`~/.cc-switch/settings.json`(设备级 UI 偏好设置)
|
||||
- **备份**:`~/.cc-switch/backups/`(自动轮换,保留最近 10 个)
|
||||
- **SKILLS**:`~/.cc-switch/skills/`(默认通过软链接连接到对应应用)
|
||||
- **技能备份**:`~/.cc-switch/skill-backups/`(卸载前自动创建,保留最近 20 个)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -246,7 +247,7 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
|
||||
### 系统要求
|
||||
|
||||
- **Windows**:Windows 10 及以上
|
||||
- **macOS**:macOS 10.15 (Catalina) 及以上
|
||||
- **macOS**:macOS 12 (Monterey) 及以上
|
||||
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||
|
||||
### Windows 用户
|
||||
@@ -270,9 +271,9 @@ brew upgrade --cask cc-switch
|
||||
|
||||
**方式二:手动下载**
|
||||
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.dmg`(推荐)或 `.zip`。
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开。
|
||||
> **注意**:CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接安装打开。
|
||||
|
||||
### Arch Linux 用户
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 902 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 895 KiB |
@@ -19,3 +19,4 @@
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
# CC Switch v3.12.3
|
||||
|
||||
> GitHub Copilot Reverse Proxy, macOS Code Signing & Notarization, Reasoning Effort Mapping, OpenCode SQLite Backend
|
||||
|
||||
**[中文版 →](v3.12.3-zh.md) | [日本語版 →](v3.12.3-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.12.3 is a major feature release that adds GitHub Copilot reverse proxy support with a dedicated Auth Center, introduces macOS code signing and Apple notarization for a seamless install experience, maps reasoning effort levels across providers, migrates OpenCode to a SQLite backend, enables Tool Search via the native `ENABLE_TOOL_SEARCH` environment variable toggle, and delivers a full skill backup/restore lifecycle. Additional improvements include proxy gzip compression, o-series model compatibility, Skills import rework, Ghostty terminal fix, Skills cache strategy optimization, Claude 4.6 context window update, and multiple bug fixes.
|
||||
|
||||
**Release Date**: 2026-03-24
|
||||
|
||||
**Update Scale**: 36 commits | 107 files changed | +9,124 / -802 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **GitHub Copilot reverse proxy**: Full Copilot proxy support with OAuth device flow authentication, token refresh, and request fingerprint emulation ([⚠️ Risk Notice](#️-risk-notice))
|
||||
- **Copilot Auth Center**: Dedicated authentication management UI for GitHub Copilot OAuth flow with token status display and one-click refresh
|
||||
- **macOS code signing & notarization**: macOS builds are now code-signed and notarized by Apple, eliminating the "unidentified developer" warning entirely
|
||||
- **Reasoning Effort mapping**: Proxy-layer auto-mapping — explicit `output_config.effort` takes priority, falling back to `budget_tokens` thresholds (<4 000→low, 4 000–16 000→medium, ≥16 000→high) for o-series and GPT-5+ models
|
||||
- **OpenCode SQLite backend**: Added SQLite session storage for OpenCode alongside existing JSON backend; dual-backend scan with SQLite priority on ID conflicts
|
||||
- **Codex 1M context window toggle**: One-click checkbox to set `model_context_window = 1000000` with auto-populated `model_auto_compact_token_limit`
|
||||
- **Disable Auto-Upgrade toggle**: Added `DISABLE_AUTOUPDATER` env var checkbox in the Claude Common Config editor to prevent Claude Code from auto-upgrading
|
||||
- **Tool Search env var toggle**: Tool Search enabled via Claude 2.1.76+ native `ENABLE_TOOL_SEARCH` environment variable in the Common Config editor — no binary patching required
|
||||
- **Skill backup/restore lifecycle**: Skills are automatically backed up before uninstall; backup list with restore and delete management added
|
||||
- **Proxy gzip compression**: Non-streaming proxy requests now auto-negotiate gzip compression, reducing bandwidth usage
|
||||
- **o-series model compatibility**: Chat Completions proxy correctly uses `max_completion_tokens` for o1/o3/o4-mini models; Responses API kept on the correct `max_output_tokens` field
|
||||
- **Skills import rework**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation
|
||||
- **Ghostty terminal support**: Fixed Claude session restore in Ghostty terminal
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
### GitHub Copilot Reverse Proxy
|
||||
|
||||
Added full reverse proxy support for GitHub Copilot, enabling Copilot-authenticated requests to be forwarded through CC Switch.
|
||||
|
||||
- Implements OAuth device flow authentication for GitHub Copilot
|
||||
- Automatic token refresh and session management
|
||||
- Request fingerprint emulation for seamless compatibility
|
||||
- Integrated into the existing proxy infrastructure alongside Claude, Codex, and Gemini handlers
|
||||
|
||||
### Copilot Auth Center
|
||||
|
||||
A dedicated authentication management UI for GitHub Copilot.
|
||||
|
||||
- OAuth device flow with code display and browser-based authorization
|
||||
- Token status display showing expiration and validity
|
||||
- One-click token refresh without re-authentication
|
||||
- Integrated into the settings panel for easy access
|
||||
|
||||
### Reasoning Effort Mapping
|
||||
|
||||
Proxy-layer auto-mapping of reasoning effort for OpenAI o-series and GPT-5+ models.
|
||||
|
||||
- Two-tier resolution: explicit `output_config.effort` takes priority, falling back to thinking `budget_tokens` thresholds (<4 000→low, 4 000–16 000→medium, ≥16 000→high)
|
||||
- Covers both Chat Completions and Responses API paths with 17 unit tests
|
||||
|
||||
### OpenCode SQLite Backend
|
||||
|
||||
Added SQLite session storage support for OpenCode alongside the existing JSON backend.
|
||||
|
||||
- Dual-backend scan with SQLite priority on ID conflicts
|
||||
- Atomic session deletion and path validation
|
||||
- JSON backend remains functional for backwards compatibility
|
||||
|
||||
### Codex 1M Context Window Toggle
|
||||
|
||||
Added a one-click toggle for Codex 1M context window in the config editor.
|
||||
|
||||
- Checkbox sets `model_context_window = 1000000` in `config.toml`
|
||||
- Auto-populates `model_auto_compact_token_limit = 900000` when enabled
|
||||
- Unchecking removes both fields cleanly
|
||||
|
||||
### Disable Auto-Upgrade Toggle
|
||||
|
||||
Added a checkbox in the Claude Common Config editor to disable Claude Code auto-upgrades.
|
||||
|
||||
- Sets `DISABLE_AUTOUPDATER=1` in the environment configuration when enabled
|
||||
- Displayed alongside Teammates mode, Tool Search, and High Effort toggles
|
||||
|
||||
### Tool Search Environment Variable Toggle
|
||||
|
||||
Tool Search is now enabled via the native `ENABLE_TOOL_SEARCH` environment variable introduced in Claude 2.1.76+.
|
||||
|
||||
- Toggle available in the Common Config editor under environment variables
|
||||
- Sets `ENABLE_TOOL_SEARCH=1` in the Claude environment configuration
|
||||
- No binary patching required — uses Claude's built-in support
|
||||
|
||||
### macOS Code Signing & Notarization
|
||||
|
||||
macOS builds are now code-signed and notarized by Apple.
|
||||
|
||||
- Application signed with a valid Apple Developer certificate
|
||||
- Notarized through Apple's notarization service for Gatekeeper approval
|
||||
- DMG installer also signed and notarized
|
||||
- Eliminates the "unidentified developer" warning on first launch
|
||||
|
||||
### Skill Auto-Backup on Uninstall
|
||||
|
||||
Skill files are now automatically backed up before uninstall to prevent accidental data loss.
|
||||
|
||||
- Backups stored in `~/.cc-switch/skill-backups/` with all skill files and a `meta.json` containing original metadata
|
||||
- Old backups are automatically pruned to keep at most 20
|
||||
- Backup path is returned to the frontend and shown in the success toast
|
||||
|
||||
### Skill Backup Restore & Delete
|
||||
|
||||
Added management commands for skill backups created during uninstall.
|
||||
|
||||
- List all available skill backups with metadata
|
||||
- Restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure
|
||||
- Delete removes the backup directory after a confirmation dialog
|
||||
- ConfirmDialog gains a configurable zIndex prop to support nested dialog stacking
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### Skills Cache Strategy Optimization
|
||||
|
||||
Optimized the Skills cache invalidation strategy for better performance.
|
||||
|
||||
- Reduced unnecessary cache refreshes during skill operations
|
||||
- Improved cache coherence between skill install/uninstall and list queries
|
||||
|
||||
### Claude 4.6 Context Window Update
|
||||
|
||||
Updated Claude 4.6 model preset with the latest context window size.
|
||||
|
||||
- Reflects the expanded context window for Claude 4.6 models
|
||||
- Updated in provider presets for accurate model information display
|
||||
|
||||
### MiniMax M2.7 Upgrade
|
||||
|
||||
- Updated MiniMax provider preset to M2.7 model variant
|
||||
|
||||
### Xiaomi MiMo Upgrade
|
||||
|
||||
- Updated Xiaomi MiMo provider preset to the latest model version
|
||||
|
||||
### AddProviderDialog Simplification
|
||||
|
||||
- Removed redundant OAuth tab, reducing dialog from 3 tabs to 2 (app-specific + universal)
|
||||
|
||||
### Provider Form Advanced Options Collapse
|
||||
|
||||
- Model mapping, API format, and other advanced fields in the Claude provider form now auto-collapse when empty
|
||||
- Auto-expands when any value is set or when a preset fills them in; does not auto-collapse when manually cleared
|
||||
|
||||
### Proxy Gzip Compression
|
||||
|
||||
Non-streaming proxy requests now support gzip compression for reduced bandwidth usage.
|
||||
|
||||
- Non-streaming requests let reqwest auto-negotiate gzip and transparently decompress responses
|
||||
- Streaming requests conservatively keep `Accept-Encoding: identity` to avoid decompression errors on interrupted SSE streams
|
||||
|
||||
### o1/o3 Model Compatibility
|
||||
|
||||
Proxy forwarding now handles OpenAI o-series model token parameters correctly.
|
||||
|
||||
- Chat Completions path uses `max_completion_tokens` instead of `max_tokens` for o1/o3/o4-mini models (#1451, thanks @Hemilt0n)
|
||||
- Responses API path kept on the correct `max_output_tokens` field instead of incorrectly injecting `max_completion_tokens`
|
||||
|
||||
### OpenCode Model Variants
|
||||
|
||||
- Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
|
||||
|
||||
### Skills Import Flow
|
||||
|
||||
The Skills import flow has been reworked for correctness and cleanup.
|
||||
|
||||
- Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation when the same skill directory exists under multiple app paths
|
||||
- Added reconciliation to `sync_to_app` to remove disabled/orphaned symlinks
|
||||
- MCP `sync_all_enabled` now removes disabled servers from live config
|
||||
- Schema migration preserves a snapshot of legacy app mappings to avoid lossy reconstruction
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### WebDAV Password Clearing
|
||||
|
||||
- Fixed an issue where the WebDAV password was silently cleared when saving unrelated settings
|
||||
|
||||
### Tool Message Parsing
|
||||
|
||||
- Fixed incorrect parsing of tool-use messages in certain proxy response formats
|
||||
|
||||
### Dark Mode Styling
|
||||
|
||||
- Fixed dark mode rendering inconsistencies in UI components
|
||||
|
||||
### Copilot Request Fingerprint
|
||||
|
||||
- Fixed request fingerprint generation for Copilot proxy to match expected format
|
||||
|
||||
### Provider Form Double Submit
|
||||
|
||||
- Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352, thanks @Hexi1997)
|
||||
|
||||
### Ghostty Session Restore
|
||||
|
||||
- Fixed Claude session restore in Ghostty terminal (#1506, thanks @canyonsehun)
|
||||
|
||||
### Skill ZIP Import Extension
|
||||
|
||||
- Added `.skill` file extension support in ZIP import dialog (#1240, #1455, thanks @yovinchen)
|
||||
|
||||
### Skill ZIP Install Target App
|
||||
|
||||
- ZIP skill installs now use the currently active app instead of always defaulting to Claude
|
||||
|
||||
### OpenClaw Active Card Highlight
|
||||
|
||||
- Fixed active OpenClaw provider card not being highlighted (#1419, thanks @funnytime75)
|
||||
|
||||
### Responsive Layout with TOC
|
||||
|
||||
- Improved responsive design when TOC title exists (#1491, thanks @West-Pavilion)
|
||||
|
||||
### Import Skills Dialog White Screen
|
||||
|
||||
- Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog
|
||||
|
||||
### Panel Bottom Blank Area
|
||||
|
||||
- Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values on different platforms
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Pricing Model ID Normalization
|
||||
|
||||
- Added documentation section explaining model ID normalization rules (prefix stripping, suffix trimming, `@`→`-` replacement) in EN/ZH/JA user manuals (#1591, thanks @makoMakoGo)
|
||||
|
||||
### macOS Signed Build Messaging
|
||||
|
||||
- Removed all `xattr` workaround instructions and "unidentified developer" warnings from README, README_ZH, installation guides (EN/ZH/JA), and FAQ pages (EN/ZH/JA); replaced with "signed and notarized by Apple" messaging
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Notice
|
||||
|
||||
**GitHub Copilot Reverse Proxy Disclaimer**
|
||||
|
||||
The Copilot reverse proxy feature introduced in this release accesses GitHub Copilot services through reverse-engineered, unofficial APIs. Please be aware of the following risks before enabling this feature:
|
||||
|
||||
1. **Terms of Service**: This feature may violate [GitHub's Acceptable Use Policies](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies) and [Terms for Additional Products and Features](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features), which prohibit excessive automated bulk activity, unauthorized service reproduction, and placing undue burden on servers through automated means.
|
||||
2. **Account Risk**: There are documented cases of GitHub issuing warning emails to users of similar tools, citing "scripted interactions or otherwise deliberately unusual or strenuous" usage patterns. Continued use after a warning may result in temporary or permanent suspension of Copilot access.
|
||||
3. **No Guarantee**: GitHub may update its detection mechanisms at any time, and usage patterns that work today may be flagged in the future.
|
||||
|
||||
Users enable this feature **at their own risk**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions resulting from the use of this feature.
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.12.3-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-macOS.dmg` | **Recommended** - DMG installer, drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.zip` | ZIP archive, extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> macOS builds are code-signed and notarized by Apple for a seamless install experience.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation Method |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
|
||||
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,313 @@
|
||||
# CC Switch v3.12.3
|
||||
|
||||
> GitHub Copilot リバースプロキシ、macOS コード署名と公証、Reasoning Effort マッピング、Tool Search 環境変数トグル、Skill バックアップ/リストア、OpenCode SQLite バックエンド
|
||||
|
||||
**[中文版 →](v3.12.3-zh.md) | [English →](v3.12.3-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.12.3 は、GitHub Copilot リバースプロキシと Copilot Auth Center を追加し、Copilot トークンを使用した Claude/OpenAI API へのアクセスを実現しました。macOS ビルドに Apple コード署名と公証を導入し、「開発元を確認できません」の警告を解消しました。Reasoning Effort マッピングにより、Claude の thinking budget を OpenAI 互換の reasoning_effort パラメータに自動変換します。Tool Search は従来のバイナリパッチ方式から Claude 2.1.76+ ネイティブの `ENABLE_TOOL_SEARCH` 環境変数トグルに移行し、共通設定エディタから切り替え可能になりました。OpenCode バックエンドを JSON から SQLite に移行し、Skill バックアップ/リストアライフサイクル、プロキシ gzip 圧縮、o シリーズモデル互換性の改善も含まれます。
|
||||
|
||||
**リリース日**: 2026-03-24
|
||||
|
||||
**更新規模**: 36 commits | 107 files changed | +9,124 / -802 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **GitHub Copilot リバースプロキシ**: Copilot トークンを使用して Claude/OpenAI API にアクセスするリバースプロキシを追加。Copilot Auth Center でトークンの取得と管理が可能([⚠️ リスクに関する注意事項](#️-リスクに関する注意事項))
|
||||
- **macOS コード署名と公証**: macOS ビルドが Apple のコード署名と公証に対応し、初回起動時の警告なしでインストール可能に。DMG インストーラーを新たに提供
|
||||
- **Reasoning Effort マッピング**: プロキシ層での自動マッピング — 明示的な `output_config.effort` を優先し、`budget_tokens` 閾値(<4000→low, 4000–16000→medium, ≥16000→high)にフォールバック。o シリーズおよび GPT-5+ モデルに対応
|
||||
- **Tool Search 環境変数トグル**: バイナリパッチ方式を廃止し、Claude 2.1.76+ ネイティブの `ENABLE_TOOL_SEARCH` 環境変数による切り替えに移行。共通設定エディタから設定可能
|
||||
- **Skill バックアップ/リストアライフサイクル**: アンインストール前に Skill ファイルを自動バックアップ。バックアップリスト、リストア、削除の管理機能を追加
|
||||
- **OpenCode SQLite バックエンド**: OpenCode に SQLite セッションストレージを追加(既存の JSON バックエンドと併存)。ID 競合時は SQLite を優先するデュアルバックエンドスキャン
|
||||
- **Codex 1M コンテキストウィンドウトグル**: 設定エディタでワンクリックで `model_context_window = 1000000` を設定可能。`model_auto_compact_token_limit` も自動設定
|
||||
- **自動アップグレード無効化トグル**: Claude 共通設定エディタに `DISABLE_AUTOUPDATER` 環境変数のチェックボックスを追加し、Claude Code の自動アップグレードを防止
|
||||
- **プロキシ Gzip 圧縮**: 非ストリーミングプロキシリクエストが gzip 圧縮を自動ネゴシエーションし、帯域幅消費を削減
|
||||
- **o シリーズモデル互換性**: Chat Completions プロキシが o1/o3/o4-mini モデルに `max_completion_tokens` を正しく使用。Responses API は正しい `max_output_tokens` フィールドを維持
|
||||
- **Skills インポートの刷新**: ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、複数アプリの誤った有効化を防止
|
||||
- **Ghostty ターミナルサポート**: Ghostty ターミナルでの Claude セッション復元を修正
|
||||
|
||||
---
|
||||
|
||||
## 新機能
|
||||
|
||||
### GitHub Copilot リバースプロキシ
|
||||
|
||||
GitHub Copilot トークンを使用して Claude API および OpenAI API にアクセスするリバースプロキシ機能を追加しました。
|
||||
|
||||
- Copilot のアクセストークンを利用し、Claude Code や Codex などのクライアントからプロキシ経由で API リクエストを転送
|
||||
- Copilot 固有のリクエストフィンガープリントとヘッダー処理に対応
|
||||
- プロバイダープリセットに Copilot 用テンプレートを追加
|
||||
|
||||
### Copilot Auth Center
|
||||
|
||||
Copilot トークンの取得と管理を行う認証センターを追加しました。
|
||||
|
||||
- GitHub デバイスフローによるトークン取得をサポート
|
||||
- トークンの有効期限管理と自動リフレッシュ
|
||||
- フロントエンドから直接トークンステータスの確認と再認証が可能
|
||||
|
||||
### Reasoning Effort マッピング
|
||||
|
||||
OpenAI o シリーズおよび GPT-5+ モデル向けのプロキシ層自動マッピング機能を追加しました。
|
||||
|
||||
- 二段階の解決ロジック:明示的な `output_config.effort` を優先し、thinking `budget_tokens` 閾値(<4000→low, 4000–16000→medium, ≥16000→high)にフォールバック
|
||||
- Chat Completions と Responses API の両パスをカバー、17 個のユニットテスト付き
|
||||
|
||||
### Tool Search 環境変数トグル
|
||||
|
||||
Claude CLI Tool Search の有効化/無効化を環境変数で制御する設定を追加しました。
|
||||
|
||||
- Claude 2.1.76+ で導入されたネイティブの `ENABLE_TOOL_SEARCH` 環境変数を使用
|
||||
- 共通設定(Common Config)エディタから直接トグル可能
|
||||
- 従来のバイナリパッチ方式は不要になり、CLI アップデート時の再適用も不要
|
||||
|
||||
### Skill アンインストール時の自動バックアップ
|
||||
|
||||
アンインストール前に Skill ファイルを自動バックアップし、意図しないデータ損失を防止します。
|
||||
|
||||
- バックアップは `~/.cc-switch/skill-backups/` に保存され、すべての skill ファイルと元のメタデータを含む `meta.json` が含まれます
|
||||
- 古いバックアップは自動的にプルーニングされ、最大 20 個を保持
|
||||
- バックアップパスはフロントエンドに返され、成功トーストに表示
|
||||
|
||||
### Skill バックアップのリストアと削除
|
||||
|
||||
アンインストール時に作成された Skill バックアップの管理コマンドを追加しました。
|
||||
|
||||
- すべての利用可能な skill バックアップをメタデータ付きで一覧表示
|
||||
- リストアはファイルを SSOT にコピーし、DB レコードを保存し、現在のアプリに同期。失敗時は自動ロールバック
|
||||
- 削除は確認ダイアログの後にバックアップディレクトリを削除
|
||||
- ConfirmDialog にネストされたダイアログスタッキングをサポートする設定可能な zIndex プロパティを追加
|
||||
|
||||
### OpenCode SQLite バックエンド
|
||||
|
||||
OpenCode に SQLite セッションストレージサポートを追加しました(既存の JSON バックエンドと併存)。
|
||||
|
||||
- デュアルバックエンドスキャン、ID 競合時は SQLite を優先
|
||||
- アトミックなセッション削除とパス検証
|
||||
- JSON バックエンドは後方互換性のため引き続き機能
|
||||
|
||||
### Codex 1M コンテキストウィンドウトグル
|
||||
|
||||
設定エディタに Codex 1M コンテキストウィンドウのワンクリックトグルを追加しました。
|
||||
|
||||
- チェックボックスで `config.toml` に `model_context_window = 1000000` を設定
|
||||
- 有効化時に `model_auto_compact_token_limit = 900000` を自動設定
|
||||
- 無効化時は両フィールドをクリーンに削除
|
||||
|
||||
### 自動アップグレード無効化トグル
|
||||
|
||||
Claude 共通設定エディタに自動アップグレードを無効化するチェックボックスを追加しました。
|
||||
|
||||
- 有効化時に `DISABLE_AUTOUPDATER=1` 環境変数を設定し、Claude Code の自動アップグレードを防止
|
||||
- Teammates モード、Tool Search、高強度思考トグルと同じ行に表示
|
||||
|
||||
### macOS コード署名と公証
|
||||
|
||||
macOS ビルドに Apple のコード署名と公証を導入しました。
|
||||
|
||||
- Apple Developer ID による署名と Apple 公証サービスによる公証を実施
|
||||
- 初回起動時の「開発元を確認できません」警告が不要に
|
||||
- DMG インストーラーを新たに提供し、ドラッグ&ドロップでのインストールに対応
|
||||
- CI/CD パイプラインに署名・公証ステップを統合
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### Skills キャッシュ戦略の最適化
|
||||
|
||||
Skills のキャッシュ戦略を最適化し、パフォーマンスと信頼性を向上しました。
|
||||
|
||||
- キャッシュの有効期限管理とインバリデーション戦略を改善
|
||||
- 不要なキャッシュ再構築を削減し、起動時間を短縮
|
||||
|
||||
### Claude 4.6 コンテキストウィンドウ更新
|
||||
|
||||
Claude 4.6 モデルのコンテキストウィンドウサイズを更新しました。
|
||||
|
||||
- Claude 4.6 の最新コンテキストウィンドウサイズをプリセットに反映
|
||||
|
||||
### MiniMax M2.7 アップグレード
|
||||
|
||||
MiniMax モデルプリセットを M2.7 にアップグレードしました。
|
||||
|
||||
- MiniMax プロバイダープリセットのモデル ID とパラメータを M2.7 に更新
|
||||
|
||||
### Xiaomi MiMo アップグレード
|
||||
|
||||
Xiaomi MiMo モデルプリセットをアップグレードしました。
|
||||
|
||||
- MiMo プロバイダープリセットのモデル ID とパラメータを最新版に更新
|
||||
|
||||
### AddProviderDialog の簡素化
|
||||
|
||||
- 冗長な OAuth タブを削除し、ダイアログを 3 タブから 2 タブ(アプリ固有 + ユニバーサル)に簡素化
|
||||
|
||||
### プロバイダーフォームの高度なオプション折りたたみ
|
||||
|
||||
- Claude プロバイダーフォームのモデルマッピング、API フォーマットなどの高度なフィールドが未入力時にデフォルトで折りたたまれるように変更
|
||||
- プリセットが値を入力すると自動展開。手動クリア時は自動折りたたみしない
|
||||
|
||||
### プロキシ Gzip 圧縮
|
||||
|
||||
非ストリーミングプロキシリクエストが gzip 圧縮をサポートし、帯域幅消費を削減しました。
|
||||
|
||||
- 非ストリーミングリクエストは reqwest が gzip を自動ネゴシエーションし、レスポンスを透過的に解凍
|
||||
- ストリーミングリクエストは中断された SSE ストリームの解凍エラーを避けるため、保守的に `Accept-Encoding: identity` を維持
|
||||
|
||||
### o1/o3 モデル互換性
|
||||
|
||||
プロキシ転送が OpenAI o シリーズモデルのトークンパラメータを正しく処理するようになりました。
|
||||
|
||||
- Chat Completions パスが o1/o3/o4-mini モデルに `max_tokens` の代わりに `max_completion_tokens` を使用 (#1451、@Hemilt0n に感謝)
|
||||
- Responses API パスが正しい `max_output_tokens` フィールドを維持し、`max_completion_tokens` の誤った注入を防止
|
||||
|
||||
### OpenCode モデルバリアント
|
||||
|
||||
- OpenCode のモデルバリアントを options 内部ではなくプリセットのトップレベルに配置し、発見しやすさを向上 (#1317)
|
||||
|
||||
### Skills インポートフロー
|
||||
|
||||
Skills インポートフローが正確性とクリーンアップのためにリワークされました。
|
||||
|
||||
- ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、同じ skill ディレクトリが複数アプリパスに存在する場合の複数アプリ誤有効化を防止
|
||||
- `sync_to_app` に調整ロジックを追加し、無効化/孤立したシンボリックリンクを削除
|
||||
- MCP `sync_all_enabled` がライブ設定から無効化されたサーバーを削除するように改善
|
||||
- スキーママイグレーションがレガシーアプリマッピングのスナップショットを保持し、損失のある再構築を回避
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### WebDAV パスワードの消失
|
||||
|
||||
- 無関係な設定保存時に WebDAV パスワードがサイレントにクリアされる問題を修正
|
||||
|
||||
### ツールメッセージのパース
|
||||
|
||||
- プロキシのツールメッセージパース処理の不具合を修正し、特定のツール呼び出しパターンでのエラーを解消
|
||||
|
||||
### ダークモードの表示
|
||||
|
||||
- ダークモードでの一部 UI コンポーネントの表示不具合を修正
|
||||
|
||||
### Copilot リクエストフィンガープリント
|
||||
|
||||
- Copilot リバースプロキシのリクエストフィンガープリント生成の不具合を修正し、認証エラーを解消
|
||||
|
||||
### プロバイダーフォームの二重送信
|
||||
|
||||
- プロバイダー追加/編集フォームでの高速連続クリックによる重複送信を防止 (#1352、@Hexi1997 に感謝)
|
||||
|
||||
### Ghostty ターミナルセッション復元
|
||||
|
||||
- Ghostty ターミナルでの Claude セッション復元の失敗を修正 (#1506、@canyonsehun に感謝)
|
||||
|
||||
### Skill ZIP インポート拡張子
|
||||
|
||||
- ZIP インポートダイアログが `.skill` ファイル拡張子をサポートするように修正 (#1240, #1455、@yovinchen に感謝)
|
||||
|
||||
### Skill ZIP インストール対象アプリ
|
||||
|
||||
- ZIP 方式でインストールされた skill が常に Claude をデフォルトにするのではなく、現在アクティブなアプリを使用するように修正
|
||||
|
||||
### OpenClaw アクティブカードのハイライト
|
||||
|
||||
- OpenClaw の現在アクティブなプロバイダーカードがハイライト表示されない問題を修正 (#1419、@funnytime75 に感謝)
|
||||
|
||||
### TOC 付きレスポンシブレイアウト
|
||||
|
||||
- TOC タイトルが存在する場合のレスポンシブデザインを改善 (#1491、@West-Pavilion に感謝)
|
||||
|
||||
### Skills インポートダイアログの白い画面
|
||||
|
||||
- ImportSkillsDialog に不足していた TooltipProvider を追加し、ダイアログを開く際のランタイムクラッシュを防止
|
||||
|
||||
### パネル下部の空白エリア
|
||||
|
||||
- すべてのコンテンツパネルのハードコードされた `h-[calc(100vh-8rem)]` を `flex-1 min-h-0` に置き換え、異なるプラットフォーム間のオフセット値の不一致による下部のギャップを解消
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### 料金モデル ID の正規化
|
||||
|
||||
- 中英日三言語のユーザーマニュアルにモデル ID 正規化ルール(プレフィックス除去、サフィックストリミング、`@`→`-` 置換)の説明セクションを追加 (#1591、@makoMakoGo に感謝)
|
||||
|
||||
### macOS 署名済みメッセージの更新
|
||||
|
||||
- README、README_ZH、インストールガイド(EN/ZH/JA)、FAQ ページ(EN/ZH/JA)からすべての `xattr` 回避策と「開発元を確認できません」警告を削除し、「Apple のコード署名と公証済み」メッセージに置換
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ リスクに関する注意事項
|
||||
|
||||
**GitHub Copilot リバースプロキシに関する免責事項**
|
||||
|
||||
本リリースで追加された Copilot リバースプロキシ機能は、リバースエンジニアリングによる非公式 API を通じて GitHub Copilot サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
|
||||
|
||||
1. **利用規約違反の可能性**:この機能は [GitHub 利用規約](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)および[追加製品の利用条件](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features)に違反する可能性があります。これらの規約では、過度な自動一括操作、サービスの無断複製、自動化手段によるサーバーへの過度な負荷が禁止されています。
|
||||
2. **アカウントリスク**:類似ツールの利用者が GitHub から「スクリプト化されたインタラクション、または意図的に異常もしくは過度な使用」を指摘する警告メールを受け取った事例が報告されています。警告後も使用を継続した場合、Copilot へのアクセスが一時的または永久的に停止される可能性があります。
|
||||
3. **将来の利用保証なし**:GitHub は検出メカニズムをいつでも更新する可能性があり、現在利用可能な使用パターンが将来的にフラグ付けされる可能性があります。
|
||||
|
||||
この機能を有効にすることで、ユーザーは**すべてのリスクを自己責任で負う**ものとします。CC Switch は、この機能の使用に起因するアカウント制限、警告、またはサービス停止について一切の責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最小バージョン | アーキテクチャ |
|
||||
| -------- | -------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.12.3-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-macOS.dmg` | **推奨** - DMG インストーラー、ドラッグ&ドロップでインストール |
|
||||
| `CC-Switch-v3.12.3-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> macOS 版は Apple のコード署名と公証済みで、そのままインストールしてご利用いただけます。
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
|
||||
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,296 @@
|
||||
# CC Switch v3.12.3
|
||||
|
||||
> GitHub Copilot 反向代理、macOS 代码签名与公证、Reasoning Effort 映射、Tool Search 环境变量开关、Skill 备份/恢复生命周期
|
||||
|
||||
**[English →](v3.12.3-en.md) | [日本語版 →](v3.12.3-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.12.3 新增了 **GitHub Copilot 反向代理** 支持和 **Copilot Auth Center** 认证管理,引入了 **Reasoning Effort 映射** 实现跨供应商推理强度控制,通过 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量实现了 **Tool Search 开关**,新增了 **OpenCode SQLite 后端** 支持,并完成了 **macOS 代码签名与 Apple 公证**。同时引入了完整的 Skill 备份/恢复生命周期,改进了代理对 OpenAI o 系列模型的兼容性和 gzip 压缩支持,优化了 Skills 缓存策略,更新了 Claude 4.6 上下文窗口、MiniMax M2.7 和小米 MiMo 模型预设,并修复了 WebDAV 密码、工具消息解析、暗色模式和 Copilot 请求指纹等方面的问题。
|
||||
|
||||
**发布日期**:2026-03-24
|
||||
|
||||
**更新规模**:36 commits | 107 files changed | +9,124 / -802 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **GitHub Copilot 反向代理**:新增 Copilot 反向代理支持,通过 Copilot Auth Center 管理 GitHub Token 认证,实现 Copilot 模型在 Claude Code 中的无缝使用([⚠️ 风险提示](#️-风险提示))
|
||||
- **macOS 代码签名与公证**:macOS 版本已通过 Apple 代码签名和公证,新增 DMG 安装格式,无需再手动绕过"未知开发者"警告
|
||||
- **Reasoning Effort 映射**:代理层自动映射 — 显式 `output_config.effort` 优先,回退到 `budget_tokens` 阈值(<4000→low, 4000–16000→medium, ≥16000→high),支持 o 系列和 GPT-5+ 模型
|
||||
- **Tool Search 环境变量开关**:利用 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量,在通用配置编辑器中一键启用 Tool Search
|
||||
- **Skill 备份/恢复生命周期**:卸载前自动备份 Skill 文件;新增备份列表、恢复和删除管理
|
||||
- **OpenCode SQLite 后端**:为 OpenCode 新增 SQLite 会话存储(与现有 JSON 后端并存),ID 冲突时 SQLite 优先的双后端扫描
|
||||
- **Codex 1M 上下文窗口开关**:配置编辑器中一键设置 `model_context_window = 1000000`,自动填充 `model_auto_compact_token_limit`
|
||||
- **禁用自动升级开关**:通用配置编辑器中新增 `DISABLE_AUTOUPDATER` 环境变量复选框,防止 Claude Code 自动升级
|
||||
- **代理 Gzip 压缩**:非流式代理请求自动协商 gzip 压缩,减少带宽消耗
|
||||
- **o 系列模型兼容性**:Chat Completions 代理正确使用 `max_completion_tokens` 处理 o1/o3/o4-mini 模型
|
||||
- **Skills 导入重构**:将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止多应用错误激活
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### GitHub Copilot 反向代理
|
||||
|
||||
新增完整的 GitHub Copilot 集成,作为 Claude Code 供应商使用。
|
||||
|
||||
- 通过 OAuth Device Code 流程进行 GitHub 认证
|
||||
- 支持多账号管理和自动 Token 刷新
|
||||
- Anthropic ↔ OpenAI 格式自动转换
|
||||
- 实时获取可用模型列表和用量统计 (#930,感谢 @Mason-mengze)
|
||||
|
||||
### Copilot Auth Center
|
||||
|
||||
在设置中新增认证中心面板,全局管理 GitHub 账号。
|
||||
|
||||
- 支持按供应商绑定账号(通过 `meta.authBinding`)
|
||||
- 统一的 Token 管理和刷新机制
|
||||
|
||||
### Tool Search 开关
|
||||
|
||||
利用 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量控制 Tool Search 功能。
|
||||
|
||||
- 在供应商通用配置编辑器中以复选框形式暴露
|
||||
- 替代了之前的二进制补丁方案,更简洁可靠 (#930,感谢 @Mason-mengze)
|
||||
|
||||
### Reasoning Effort 映射
|
||||
|
||||
新增代理层自动推理强度映射,支持 OpenAI o 系列和 GPT-5+ 模型。
|
||||
|
||||
- 两级解析:显式 `output_config.effort` 优先,回退到 `budget_tokens` 阈值(<4000→low, 4000–16000→medium, ≥16000→high)
|
||||
- 覆盖 Chat Completions 和 Responses API 两条路径,含 17 个单元测试
|
||||
|
||||
### OpenCode SQLite 后端
|
||||
|
||||
为 OpenCode 新增 SQLite 会话存储支持(与现有 JSON 后端并存)。
|
||||
|
||||
- 双后端扫描,ID 冲突时 SQLite 优先
|
||||
- 原子会话删除和路径校验
|
||||
- JSON 后端保持向后兼容
|
||||
|
||||
### Codex 1M 上下文窗口开关
|
||||
|
||||
在配置编辑器中新增 Codex 1M 上下文窗口一键开关。
|
||||
|
||||
- 复选框设置 `config.toml` 中的 `model_context_window = 1000000`
|
||||
- 启用时自动填充 `model_auto_compact_token_limit = 900000`
|
||||
- 关闭时干净移除两个字段
|
||||
|
||||
### 禁用自动升级开关
|
||||
|
||||
在 Claude 通用配置编辑器中新增禁用自动升级的复选框。
|
||||
|
||||
- 勾选后设置 `DISABLE_AUTOUPDATER=1` 环境变量,阻止 Claude Code 自动升级
|
||||
- 与 Teammates 模式、Tool Search、高强度思考等开关同一排显示
|
||||
|
||||
### Skill 卸载自动备份
|
||||
|
||||
卸载 Skill 前自动备份文件,防止数据意外丢失。
|
||||
|
||||
- 备份存储在 `~/.cc-switch/skill-backups/`,包含所有 skill 文件和记录原始元数据的 `meta.json`
|
||||
- 旧备份自动清理,最多保留 20 个
|
||||
- 备份路径返回前端并在成功提示中显示
|
||||
|
||||
### Skill 备份恢复与删除
|
||||
|
||||
新增卸载时创建的 Skill 备份的管理功能。
|
||||
|
||||
- 列出所有可用的 skill 备份及元数据
|
||||
- 恢复操作将文件拷回 SSOT,保存数据库记录,并同步到当前应用,失败时自动回滚
|
||||
- 删除操作在确认对话框后移除备份目录
|
||||
|
||||
### macOS 代码签名与 Apple 公证
|
||||
|
||||
CI 流程新增完整的 macOS 代码签名和 Apple 公证支持。
|
||||
|
||||
- 导入 Apple Developer ID 证书,签名 Universal Binary
|
||||
- 提交 Apple 公证并将票据装订到 `.app` 和 `.dmg`
|
||||
- 硬性验证步骤(`codesign --verify` + `spctl -a` + `stapler validate`)把关发布
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### Skills 缓存策略优化
|
||||
|
||||
- 将 `invalidateQueries` 替换为直接 `setQueryData` 更新,用于 skill 安装/卸载/导入操作
|
||||
- 新增 `staleTime: Infinity` 和 `keepPreviousData`,消除加载闪烁 (#1573,感谢 @TangZhiZzz)
|
||||
|
||||
### 代理 Gzip 压缩
|
||||
|
||||
- 非流式请求允许 reqwest 自动协商 gzip 并透明解压响应
|
||||
- 流式请求保守地保持 `Accept-Encoding: identity`,避免中断的 SSE 流解压出错
|
||||
|
||||
### o1/o3 模型兼容性
|
||||
|
||||
- Chat Completions 路径对 o1/o3/o4-mini 模型使用 `max_completion_tokens` 替代 `max_tokens` (#1451,感谢 @Hemilt0n)
|
||||
- Responses API 路径保持使用正确的 `max_output_tokens` 字段
|
||||
|
||||
### OpenCode 模型变体
|
||||
|
||||
- 将 OpenCode 的模型变体放在预设顶层而非嵌套在 options 内部,提升可发现性 (#1317)
|
||||
|
||||
### Skills 导入流程
|
||||
|
||||
- 将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止同一 skill 目录存在于多个应用路径下时错误激活多个应用
|
||||
- 为 `sync_to_app` 增加协调逻辑,移除已禁用/孤立的符号链接
|
||||
- MCP `sync_all_enabled` 现在会从 live 配置中移除已禁用的服务器
|
||||
|
||||
### Claude 4.6 上下文窗口
|
||||
|
||||
- Claude Opus 4.6 和 Sonnet 4.6 上下文窗口从 200K 更新至 1M(GA 发布)
|
||||
|
||||
### MiniMax 模型升级
|
||||
|
||||
- MiniMax 预设从 M2.5 升级至 M2.7,更新三语合作伙伴描述
|
||||
|
||||
### 小米 MiMo 模型升级
|
||||
|
||||
- MiMo 预设从 mimo-v2-flash 升级至 mimo-v2-pro
|
||||
|
||||
### 添加供应商对话框简化
|
||||
|
||||
- 移除冗余的 OAuth 标签页,对话框从 3 个标签页减少到 2 个(应用专属 + 通用)
|
||||
|
||||
### 供应商表单高级选项折叠
|
||||
|
||||
- Claude 供应商表单中的模型映射、API 格式等高级字段在未填写时默认折叠
|
||||
- 预设填充值后自动展开,手动清空不会自动折叠
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### WebDAV 密码被静默清除
|
||||
|
||||
- 修复 ProviderList 或 UsageScriptModal 保存设置时 WebDAV 密码被静默清除的问题
|
||||
- 前端 payload 中剥离 `webdavSync`,后端 `merge_settings_for_save()` 增加回填逻辑保护现有密码
|
||||
|
||||
### 工具消息解析
|
||||
|
||||
- 修复 Claude(tool_result content blocks)、Codex(function_call/function_call_output payloads)和 Gemini(array content + toolCalls extraction)的 tool_use/tool_result 消息分类 (#1401,感谢 @BlueOcean223)
|
||||
|
||||
### 暗色模式选择器
|
||||
|
||||
- 将 Tailwind `darkMode` 从 `["selector", "class"]` 改为 `["selector", ".dark"]`,确保暗色模式正确激活 (#1596,感谢 @qinxiandiqi)
|
||||
|
||||
### Copilot 请求指纹
|
||||
|
||||
- 统一所有 Copilot API 调用点的请求指纹头,防止 User-Agent 泄漏和 Stream Check 不匹配
|
||||
|
||||
### 供应商表单防重复提交
|
||||
|
||||
- 修复快速连续点击按钮时供应商添加/编辑表单重复提交的问题 (#1352,感谢 @Hexi1997)
|
||||
|
||||
### Ghostty 终端会话恢复
|
||||
|
||||
- 修复在 Ghostty 终端中恢复 Claude 会话失败的问题 (#1506,感谢 @canyonsehun)
|
||||
|
||||
### Skill ZIP 导入扩展名
|
||||
|
||||
- ZIP 导入对话框现在支持 `.skill` 文件扩展名 (#1240, #1455,感谢 @yovinchen)
|
||||
|
||||
### Skill ZIP 安装目标应用
|
||||
|
||||
- ZIP 方式安装的 skill 现在使用当前活跃应用,而非始终默认为 Claude
|
||||
|
||||
### OpenClaw 活跃供应商高亮
|
||||
|
||||
- 修复 OpenClaw 当前激活的供应商卡片未高亮显示的问题 (#1419,感谢 @funnytime75)
|
||||
|
||||
### 响应式布局与 TOC
|
||||
|
||||
- 改善存在 TOC 标题时的响应式布局 (#1491,感谢 @West-Pavilion)
|
||||
|
||||
### Skills 导入对话框白屏
|
||||
|
||||
- 在 ImportSkillsDialog 中补充缺失的 TooltipProvider,修复打开对话框时的运行时崩溃
|
||||
|
||||
### 面板底部空白区域
|
||||
|
||||
- 将所有内容面板的硬编码 `h-[calc(100vh-8rem)]` 替换为 `flex-1 min-h-0`,消除因不同平台偏移量不匹配导致的底部空白
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### 定价模型 ID 归一化
|
||||
|
||||
- 在中英日三语用户手册中新增模型 ID 归一化规则说明(前缀剥离、后缀修剪、`@`→`-` 替换)(#1591,感谢 @makoMakoGo)
|
||||
|
||||
### macOS 签名与公证说明
|
||||
|
||||
- 移除 README、安装指南和 FAQ 中所有 `xattr` 变通方案和"未知开发者"警告
|
||||
- 替换为"已通过 Apple 代码签名和公证"的说明
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 风险提示
|
||||
|
||||
**GitHub Copilot 反向代理免责声明**
|
||||
|
||||
本版本新增的 Copilot 反向代理功能通过逆向工程的非官方 API 访问 GitHub Copilot 服务。启用此功能前,请注意以下风险:
|
||||
|
||||
1. **违反服务条款**:此功能可能违反 [GitHub 可接受使用政策](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)和[附加产品条款](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features),其中禁止过度自动化批量活动、未经授权的服务复制以及通过自动化手段对服务器施加不当负担。
|
||||
2. **账号风险**:已有类似工具的用户收到 GitHub 官方警告邮件,指出其存在"脚本化交互或其他刻意的异常或高强度使用"行为。收到警告后继续使用可能导致 Copilot 访问权限被暂停甚至永久封禁。
|
||||
3. **无法保证长期可用**:GitHub 可能随时更新其检测机制,当前可用的使用方式未来可能被标记。
|
||||
|
||||
用户启用此功能即表示**自行承担所有风险**。CC Switch 不对因使用此功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------------------ | ----------------------------------- |
|
||||
| `CC-Switch-v3.12.3-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.12.3-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.12.3-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -131,21 +131,9 @@ brew upgrade --cask cc-switch
|
||||
2. Extract to get `CC Switch.app`
|
||||
3. Drag it to the Applications folder
|
||||
|
||||
### First Launch Warning
|
||||
### Signed and Notarized
|
||||
|
||||
Since the developer does not have an Apple Developer account, a "developer cannot be verified" warning may appear on first launch:
|
||||
|
||||
**Recommended solution**:
|
||||
Open Terminal and run the following command:
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
**Alternative solution (via System Settings)**:
|
||||
1. Close the warning dialog
|
||||
2. Open "System Settings" > "Privacy & Security"
|
||||
3. Find the CC Switch prompt and click "Open Anyway"
|
||||
4. Reopen the app to use it normally
|
||||
CC Switch for macOS is signed and notarized by Apple. You can install and open it directly — no extra steps needed.
|
||||
|
||||
## Linux
|
||||
|
||||
|
||||
@@ -186,6 +186,22 @@ Set prices for each model (per million tokens):
|
||||
| Cache Read Price | Price per million cache hit tokens |
|
||||
| Cache Creation Price | Price per million cache creation tokens |
|
||||
|
||||
### Model ID Normalization Rules
|
||||
|
||||
Before matching pricing, CC Switch normalizes the requested model ID:
|
||||
|
||||
- Remove everything before the last `/`
|
||||
- Remove everything after `:`
|
||||
- Replace `@` with `-`
|
||||
|
||||
When adding pricing entries, enter the normalized Model ID rather than the full raw model name from the request.
|
||||
|
||||
| Raw model name | Model ID to enter | Note |
|
||||
|-------|-------------------|------|
|
||||
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | Removes the provider prefix |
|
||||
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | Removes the prefix and the `:` suffix |
|
||||
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | Replaces `@` with `-` |
|
||||
|
||||
### Operations
|
||||
|
||||
- **Add**: Click the "Add" button to add model pricing
|
||||
|
||||
@@ -2,23 +2,9 @@
|
||||
|
||||
## Installation Issues
|
||||
|
||||
### macOS Shows "Unidentified Developer"
|
||||
### macOS Installation
|
||||
|
||||
**Problem**: First launch shows "Cannot open because it is from an unidentified developer"
|
||||
|
||||
**Solution 1**: Via System Settings
|
||||
1. Close the warning dialog
|
||||
2. Open "System Settings" > "Privacy & Security"
|
||||
3. Find the CC Switch prompt
|
||||
4. Click "Open Anyway"
|
||||
5. Reopen the app
|
||||
|
||||
**Solution 2**: Via Terminal command (recommended)
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
The app can be opened normally after running this command.
|
||||
CC Switch for macOS is code-signed and notarized by Apple. You can download and install it directly without any additional steps. If you encounter issues, try downloading the latest version from the [Releases page](https://github.com/farion1231/cc-switch/releases).
|
||||
|
||||
### Windows: App Doesn't Launch After Installation
|
||||
|
||||
|
||||
@@ -131,21 +131,9 @@ brew upgrade --cask cc-switch
|
||||
2. 展開して `CC Switch.app` を取得
|
||||
3. 「アプリケーション」フォルダにドラッグ
|
||||
|
||||
### 初回起動時の警告
|
||||
### 署名・公証済み
|
||||
|
||||
開発者が Apple 開発者アカウントを持っていないため、初回起動時に「不明な開発者」の警告が表示される場合があります:
|
||||
|
||||
**推奨される解決方法**:
|
||||
ターミナルで以下のコマンドを実行してください:
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
**別の解決方法(システム設定から)**:
|
||||
1. 警告ダイアログを閉じる
|
||||
2. 「システム設定」→「プライバシーとセキュリティ」を開く
|
||||
3. CC Switch に関する表示を見つけ、「このまま開く」をクリック
|
||||
4. 再度アプリを開くと正常に使用可能
|
||||
CC Switch の macOS 版は Apple のコード署名と公証を受けています。追加の操作なしで直接インストールして開くことができます。
|
||||
|
||||
## Linux
|
||||
|
||||
|
||||
@@ -186,6 +186,22 @@ Token 使用量の変化を表示:
|
||||
| キャッシュ読取価格 | 100 万キャッシュヒット Token あたりの価格 |
|
||||
| キャッシュ作成価格 | 100 万キャッシュ作成 Token あたりの価格 |
|
||||
|
||||
### モデル ID の正規化ルール
|
||||
|
||||
料金を照合する前に、CC Switch はリクエスト内のモデル ID を正規化します:
|
||||
|
||||
- 最後の `/` より前の接頭辞を削除
|
||||
- `:` 以降の接尾辞を削除
|
||||
- `@` を `-` に置換
|
||||
|
||||
料金設定では、リクエスト内の完全な元のモデル名ではなく、正規化後のモデル ID を入力してください。
|
||||
|
||||
| 元のモデル名 | 入力するモデル ID | 説明 |
|
||||
|------|------|------|
|
||||
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | プロバイダー接頭辞を削除 |
|
||||
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 接頭辞と `:` 以降を削除 |
|
||||
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | `@` を `-` に置換 |
|
||||
|
||||
### 操作
|
||||
|
||||
- **追加**:「追加」ボタンで新しいモデル価格を追加
|
||||
|
||||
@@ -2,23 +2,9 @@
|
||||
|
||||
## インストールに関する問題
|
||||
|
||||
### macOS で「不明な開発者」と表示される
|
||||
### macOS のインストール
|
||||
|
||||
**問題**:初回起動時に「開けません。身元不明の開発者のものです」と表示される
|
||||
|
||||
**解決方法 1**:システム設定から
|
||||
1. 警告ダイアログを閉じる
|
||||
2. 「システム設定」→「プライバシーとセキュリティ」を開く
|
||||
3. CC Switch に関する表示を見つける
|
||||
4. 「このまま開く」をクリック
|
||||
5. 再度アプリを開く
|
||||
|
||||
**解決方法 2**:ターミナルコマンドから(推奨)
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
実行後、正常にアプリを開けるようになります。
|
||||
CC Switch の macOS 版は Apple のコード署名と公証を受けています。追加の操作なしで直接ダウンロードしてインストールできます。問題が発生した場合は、[Releases ページ](https://github.com/farion1231/cc-switch/releases) から最新版をダウンロードしてください。
|
||||
|
||||
### Windows でインストール後に起動できない
|
||||
|
||||
|
||||
@@ -145,21 +145,9 @@ brew upgrade --cask cc-switch
|
||||
2. 解压得到 `CC Switch.app`
|
||||
3. 拖动到「应用程序」文件夹
|
||||
|
||||
### 首次打开提示
|
||||
### 已签名并公证
|
||||
|
||||
由于开发者没有 Apple 开发者账号,首次打开可能出现「未知开发者」警告:
|
||||
|
||||
**推荐解决方法**:
|
||||
打开终端执行以下命令:
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
**备选解决方法(通过系统设置)**:
|
||||
1. 关闭警告弹窗
|
||||
2. 打开「系统设置」→「隐私与安全性」
|
||||
3. 找到 CC Switch 相关提示,点击「仍要打开」
|
||||
4. 再次打开应用即可正常使用
|
||||
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接安装打开,无需额外操作。
|
||||
|
||||
## Linux
|
||||
|
||||
|
||||
@@ -186,6 +186,22 @@
|
||||
| 缓存读取价格 | 每百万缓存命中 Token 的价格 |
|
||||
| 缓存创建价格 | 每百万缓存创建 Token 的价格 |
|
||||
|
||||
### 模型 ID 匹配规则
|
||||
|
||||
在匹配定价前,CC Switch 会先对请求中的模型 ID 做标准化处理:
|
||||
|
||||
- 去掉最后一个 `/` 之前的前缀
|
||||
- 去掉 `:` 之后的后缀
|
||||
- 将 `@` 替换为 `-`
|
||||
|
||||
因此,在定价配置中请填写清洗后的模型 ID,而不是请求里的完整原始模型名。
|
||||
|
||||
| 原始模型名 | 应填写的模型 ID | 说明 |
|
||||
|------|------|------|
|
||||
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | 去掉供应商前缀 |
|
||||
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 去掉前缀和 `:` 后缀 |
|
||||
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | 将 `@` 替换为 `-` |
|
||||
|
||||
### 操作
|
||||
|
||||
- **添加**:点击「添加」按钮新增模型定价
|
||||
|
||||
@@ -2,23 +2,9 @@
|
||||
|
||||
## 安装问题
|
||||
|
||||
### macOS 提示「未知开发者」
|
||||
### macOS 安装
|
||||
|
||||
**问题**:首次打开时提示「无法打开,因为它来自身份不明的开发者」
|
||||
|
||||
**解决方法一**:通过系统设置
|
||||
1. 关闭警告弹窗
|
||||
2. 打开「系统设置」→「隐私与安全性」
|
||||
3. 找到 CC Switch 相关提示
|
||||
4. 点击「仍要打开」
|
||||
5. 再次打开应用
|
||||
|
||||
**解决方法二**:通过终端命令(推荐)
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
执行后即可正常打开应用。
|
||||
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安装,无需额外操作。如遇问题,请尝试从 [Releases 页面](https://github.com/farion1231/cc-switch/releases) 下载最新版本。
|
||||
|
||||
### Windows 安装后无法启动
|
||||
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.12.2",
|
||||
"version": "3.12.3",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -90,6 +90,5 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ module.exports = {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Generated
+35
-1
@@ -137,6 +137,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
@@ -672,7 +684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.12.2"
|
||||
version = "3.12.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -798,6 +810,23 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -5988,13 +6017,18 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.12.2"
|
||||
version = "3.12.3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -38,7 +38,7 @@ tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks", "gzip"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -883,6 +883,7 @@ mod tests {
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
original_test_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
@@ -890,14 +891,17 @@ mod tests {
|
||||
let dir = TempDir::new().expect("failed to create temp home");
|
||||
let original_home = env::var("HOME").ok();
|
||||
let original_userprofile = env::var("USERPROFILE").ok();
|
||||
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
|
||||
|
||||
env::set_var("HOME", dir.path());
|
||||
env::set_var("USERPROFILE", dir.path());
|
||||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||||
|
||||
Self {
|
||||
dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
original_test_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -913,6 +917,11 @@ mod tests {
|
||||
Some(value) => env::set_var("USERPROFILE", value),
|
||||
None => env::remove_var("USERPROFILE"),
|
||||
}
|
||||
|
||||
match &self.original_test_home {
|
||||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
|
||||
|
||||
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthAccount {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub login: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub authenticated_at: i64,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthStatus {
|
||||
pub provider: String,
|
||||
pub authenticated: bool,
|
||||
pub default_account_id: Option<String>,
|
||||
pub migration_error: Option<String>,
|
||||
pub accounts: Vec<ManagedAuthAccount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthDeviceCodeResponse {
|
||||
pub provider: String,
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> {
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
|
||||
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_account(
|
||||
provider: &str,
|
||||
account: GitHubAccount,
|
||||
default_account_id: Option<&str>,
|
||||
) -> ManagedAuthAccount {
|
||||
ManagedAuthAccount {
|
||||
is_default: default_account_id == Some(account.id.as_str()),
|
||||
id: account.id,
|
||||
provider: provider.to_string(),
|
||||
login: account.login,
|
||||
avatar_url: account.avatar_url,
|
||||
authenticated_at: account.authenticated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_device_code_response(
|
||||
provider: &str,
|
||||
response: GitHubDeviceCodeResponse,
|
||||
) -> ManagedAuthDeviceCodeResponse {
|
||||
ManagedAuthDeviceCodeResponse {
|
||||
provider: provider.to_string(),
|
||||
device_code: response.device_code,
|
||||
user_code: response.user_code,
|
||||
verification_uri: response.verification_uri,
|
||||
expires_in: response.expires_in,
|
||||
interval: response.interval,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_start_login(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<ManagedAuthDeviceCodeResponse, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_poll_for_account(
|
||||
auth_provider: String,
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref())))
|
||||
}
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_list_accounts(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_get_status(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<ManagedAuthStatus, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: status.migration_error,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_remove_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_set_default_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_logout(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! GitHub Copilot Tauri Commands
|
||||
//!
|
||||
//! 提供 Copilot OAuth 认证相关的 Tauri 命令,支持多账号管理。
|
||||
|
||||
use crate::proxy::providers::copilot_auth::{
|
||||
CopilotAuthManager, CopilotAuthStatus, CopilotModel, CopilotUsageResponse, GitHubAccount,
|
||||
GitHubDeviceCodeResponse,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Copilot 认证状态
|
||||
pub struct CopilotAuthState(pub Arc<RwLock<CopilotAuthManager>>);
|
||||
|
||||
// ==================== 设备码流程 ====================
|
||||
|
||||
/// 启动设备码流程
|
||||
///
|
||||
/// 返回设备码和用户码,用于 OAuth 认证
|
||||
#[tauri::command]
|
||||
pub async fn copilot_start_device_flow(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<GitHubDeviceCodeResponse, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 轮询 OAuth Token(向后兼容)
|
||||
///
|
||||
/// 使用设备码轮询 GitHub,等待用户完成授权
|
||||
/// 返回 true 表示授权成功,false 表示等待中
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_poll_for_auth(
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<bool, String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(Some(_account)) => {
|
||||
log::info!("[CopilotAuth] 用户已授权");
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => Ok(false),
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询 OAuth Token(多账号版本)
|
||||
///
|
||||
/// 返回新添加的账号信息,如果授权成功
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_poll_for_account(
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<GitHubAccount>, String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => Ok(account),
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 多账号管理 ====================
|
||||
|
||||
/// 列出所有已认证的账号
|
||||
#[tauri::command]
|
||||
pub async fn copilot_list_accounts(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<GitHubAccount>, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
Ok(auth_manager.list_accounts().await)
|
||||
}
|
||||
|
||||
/// 移除指定账号
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_remove_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置默认账号
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_set_default_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
/// 获取认证状态(包含所有账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_auth_status(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<CopilotAuthStatus, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
Ok(auth_manager.get_status().await)
|
||||
}
|
||||
|
||||
/// 检查是否已认证(有任意账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_is_authenticated(state: State<'_, CopilotAuthState>) -> Result<bool, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
Ok(auth_manager.is_authenticated().await)
|
||||
}
|
||||
|
||||
/// 注销所有 Copilot 认证
|
||||
#[tauri::command]
|
||||
pub async fn copilot_logout(state: State<'_, CopilotAuthState>) -> Result<(), String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== Token 获取 ====================
|
||||
|
||||
/// 获取有效的 Copilot Token(向后兼容:使用第一个账号)
|
||||
///
|
||||
/// 内部使用,用于代理请求
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_token(state: State<'_, CopilotAuthState>) -> Result<String, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.get_valid_token()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定账号的有效 Copilot Token
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_get_token_for_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<String, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.get_valid_token_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== 模型和使用量 ====================
|
||||
|
||||
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_models(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<CopilotModel>, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager.fetch_models().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定账号的 Copilot 可用模型列表
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_get_models_for_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<CopilotModel>, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.fetch_models_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取 Copilot 使用量信息(向后兼容:使用第一个账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_usage(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<CopilotUsageResponse, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager.fetch_usage().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定账号的 Copilot 使用量信息
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_get_usage_for_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<CopilotUsageResponse, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.fetch_usage_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -115,7 +115,7 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
||||
let dialog = app.dialog();
|
||||
let result = dialog
|
||||
.file()
|
||||
.add_filter("ZIP", &["zip"])
|
||||
.add_filter("ZIP / Skill", &["zip", "skill"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod copilot;
|
||||
mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
@@ -19,11 +21,14 @@ mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use auth::*;
|
||||
pub use config::*;
|
||||
pub use copilot::*;
|
||||
pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
@@ -41,6 +46,7 @@ pub use session_manager::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -2,6 +2,7 @@ use indexmap::IndexMap;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::{
|
||||
@@ -10,6 +11,11 @@ use crate::services::{
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
|
||||
// 常量定义
|
||||
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
||||
|
||||
/// 获取所有供应商
|
||||
#[tauri::command]
|
||||
pub fn get_providers(
|
||||
state: State<'_, AppState>,
|
||||
@@ -142,10 +148,65 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
|
||||
#[tauri::command]
|
||||
pub async fn queryProviderUsage(
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||
app: String,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 检查是否为 GitHub Copilot 模板类型,并解析绑定账号
|
||||
let (is_copilot_template, copilot_account_id) = {
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {}", e))?;
|
||||
|
||||
let provider = providers.get(&providerId);
|
||||
let is_copilot = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.and_then(|s| s.template_type.as_ref())
|
||||
.map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT)
|
||||
.unwrap_or(false);
|
||||
let account_id = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
|
||||
|
||||
(is_copilot, account_id)
|
||||
};
|
||||
|
||||
if is_copilot_template {
|
||||
// 使用 Copilot 专用 API
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let usage = match copilot_account_id.as_deref() {
|
||||
Some(account_id) => auth_manager
|
||||
.fetch_usage_for_account(account_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?,
|
||||
None => auth_manager
|
||||
.fetch_usage()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?,
|
||||
};
|
||||
let premium = &usage.quota_snapshots.premium_interactions;
|
||||
let used = premium.entitlement - premium.remaining;
|
||||
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![crate::provider::UsageData {
|
||||
plan_name: Some(usage.copilot_plan),
|
||||
remaining: Some(premium.remaining as f64),
|
||||
total: Some(premium.entitlement as f64),
|
||||
used: Some(used as f64),
|
||||
unit: Some(COPILOT_UNIT_PREMIUM.to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: Some(format!("Reset: {}", usage.quota_reset_date)),
|
||||
}]),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
@@ -6,8 +6,20 @@ fn merge_settings_for_save(
|
||||
mut incoming: crate::settings::AppSettings,
|
||||
existing: &crate::settings::AppSettings,
|
||||
) -> crate::settings::AppSettings {
|
||||
if incoming.webdav_sync.is_none() {
|
||||
incoming.webdav_sync = existing.webdav_sync.clone();
|
||||
match (&mut incoming.webdav_sync, &existing.webdav_sync) {
|
||||
// incoming 没有 webdav → 保留现有
|
||||
(None, _) => {
|
||||
incoming.webdav_sync = existing.webdav_sync.clone();
|
||||
}
|
||||
// incoming 有 webdav 但密码为空,且现有有密码 → 填回现有密码
|
||||
// (get_settings_for_frontend 总是清空密码,所以通过 save_settings
|
||||
// 传入的空密码意味着"保持现有"而非"用户主动清空")
|
||||
(Some(incoming_sync), Some(existing_sync))
|
||||
if incoming_sync.password.is_empty() && !existing_sync.password.is_empty() =>
|
||||
{
|
||||
incoming_sync.password = existing_sync.password.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
incoming
|
||||
}
|
||||
@@ -116,6 +128,66 @@ mod tests {
|
||||
Some("https://dav.new.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test: frontend always receives empty password from
|
||||
/// get_settings_for_frontend(). If a component accidentally spreads
|
||||
/// the full settings object into save_settings, the empty password
|
||||
/// must NOT overwrite the existing one.
|
||||
#[test]
|
||||
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
// Simulate frontend sending settings with cleared password
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
|
||||
Some("secret"),
|
||||
"empty password from frontend must not overwrite existing password"
|
||||
);
|
||||
}
|
||||
|
||||
/// When both incoming and existing have no password, merge should
|
||||
/// work without panicking and keep the empty state.
|
||||
#[test]
|
||||
fn save_settings_should_handle_both_empty_passwords() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||
use crate::error::format_skill_error;
|
||||
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
use crate::services::skill::{
|
||||
DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService,
|
||||
SkillUninstallResult,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -33,6 +36,17 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<Instal
|
||||
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
|
||||
SkillService::list_backups().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_skill_backup(backup_id: String) -> Result<bool, String> {
|
||||
SkillService::delete_backup(&backup_id).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 安装 Skill(新版统一安装)
|
||||
///
|
||||
/// 参数:
|
||||
@@ -56,9 +70,22 @@ pub async fn install_skill_unified(
|
||||
|
||||
/// 卸载 Skill(新版统一卸载)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
pub fn uninstall_skill_unified(
|
||||
id: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_skill_backup(
|
||||
backup_id: String,
|
||||
current_app: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<InstalledSkill, String> {
|
||||
let app_type = parse_app_type(¤t_app)?;
|
||||
SkillService::restore_from_backup(&app_state.db, &backup_id, &app_type)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 切换 Skill 的应用启用状态
|
||||
@@ -85,10 +112,10 @@ pub fn scan_unmanaged_skills(
|
||||
/// 从应用目录导入 Skills
|
||||
#[tauri::command]
|
||||
pub fn import_skills_from_apps(
|
||||
directories: Vec<String>,
|
||||
imports: Vec<ImportSkillSelection>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<InstalledSkill>, String> {
|
||||
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
|
||||
SkillService::import_from_apps(&app_state.db, imports).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 发现功能命令 ==========
|
||||
@@ -192,7 +219,10 @@ pub async fn install_skill_for_app(
|
||||
|
||||
/// 卸载技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, app_state)
|
||||
}
|
||||
|
||||
@@ -202,7 +232,7 @@ pub fn uninstall_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
let _ = parse_app_type(&app)?; // 验证参数
|
||||
|
||||
// 通过 directory 找到对应的 skill id
|
||||
@@ -213,9 +243,7 @@ pub fn uninstall_skill_for_app(
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
|
||||
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 仓库管理命令 ==========
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 流式健康检查命令
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::services::stream_check::{
|
||||
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
|
||||
@@ -13,6 +14,7 @@ use tauri::State;
|
||||
#[tauri::command]
|
||||
pub async fn stream_check_provider(
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
app_type: AppType,
|
||||
provider_id: String,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
@@ -23,7 +25,9 @@ pub async fn stream_check_provider(
|
||||
.get(&provider_id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
|
||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?;
|
||||
|
||||
// 记录日志
|
||||
let _ =
|
||||
@@ -38,6 +42,7 @@ pub async fn stream_check_provider(
|
||||
#[tauri::command]
|
||||
pub async fn stream_check_all_providers(
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
app_type: AppType,
|
||||
proxy_targets_only: bool,
|
||||
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
|
||||
@@ -67,18 +72,20 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
}
|
||||
|
||||
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
|
||||
let _ = state
|
||||
.db
|
||||
@@ -104,3 +111,46 @@ pub fn save_stream_check_config(
|
||||
) -> Result<(), AppError> {
|
||||
state.db.save_stream_check_config(&config)
|
||||
}
|
||||
|
||||
async fn resolve_copilot_auth_override(
|
||||
provider: &crate::provider::Provider,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_BASE_URL")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|url| url.contains("githubcopilot.com"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_copilot {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.github_account_id.clone());
|
||||
|
||||
let token = match account_id.as_deref() {
|
||||
Some(id) => auth_manager
|
||||
.get_valid_token_for_account(id)
|
||||
.await
|
||||
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
||||
None => auth_manager
|
||||
.get_valid_token()
|
||||
.await
|
||||
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
||||
};
|
||||
|
||||
Ok(Some(crate::proxy::providers::AuthInfo::new(
|
||||
token,
|
||||
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
|
||||
use super::{lock_conn, Database, SCHEMA_VERSION};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LegacySkillMigrationRow {
|
||||
directory: String,
|
||||
app_type: String,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 创建所有数据库表
|
||||
@@ -382,7 +389,7 @@ impl Database {
|
||||
Self::set_user_version(conn, 5)?;
|
||||
}
|
||||
5 => {
|
||||
log::info!("迁移数据库从 v5 到 v6(使用量聚合表)");
|
||||
log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一)");
|
||||
Self::migrate_v5_to_v6(conn)?;
|
||||
Self::set_user_version(conn, 6)?;
|
||||
}
|
||||
@@ -846,12 +853,31 @@ impl Database {
|
||||
|
||||
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
|
||||
|
||||
// 1. 备份旧数据(用于日志)
|
||||
// 1. 备份旧数据(用于日志和后续启动迁移)
|
||||
let old_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
log::info!("旧 skills 表有 {old_count} 条记录");
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT directory, app_type FROM skills
|
||||
WHERE installed = 1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("查询旧 skills 快照失败: {e}")))?;
|
||||
let snapshot_rows: Vec<LegacySkillMigrationRow> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(LegacySkillMigrationRow {
|
||||
directory: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("读取旧 skills 快照失败: {e}")))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::Database(format!("解析旧 skills 快照失败: {e}")))?;
|
||||
let snapshot_json = serde_json::to_string(&snapshot_rows)
|
||||
.map_err(|e| AppError::Database(format!("序列化旧 skills 快照失败: {e}")))?;
|
||||
|
||||
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
|
||||
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/,
|
||||
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
|
||||
@@ -859,6 +885,10 @@ impl Database {
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_snapshot', ?1)",
|
||||
[snapshot_json],
|
||||
);
|
||||
|
||||
// 2. 删除旧表
|
||||
conn.execute("DROP TABLE IF EXISTS skills", [])
|
||||
@@ -940,8 +970,9 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v5 -> v6 迁移:添加使用量日聚合表
|
||||
/// v5 -> v6 迁移:添加使用量日聚合表 + 统一 Copilot 模板类型
|
||||
fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
|
||||
// 1. 添加使用量日聚合表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
@@ -962,7 +993,55 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
|
||||
|
||||
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表");
|
||||
// 2. 统一 Copilot 模板类型为 github_copilot
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, app_type, meta FROM providers")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
|
||||
let mut updated = false;
|
||||
|
||||
if let Some(usage_script) = meta.get_mut("usage_script") {
|
||||
if let Some(template_type) = usage_script.get_mut("template_type") {
|
||||
if template_type == "copilot" {
|
||||
*template_type =
|
||||
serde_json::Value::String("github_copilot".to_string());
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
let new_meta_str = serde_json::to_string(&meta)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
updates.push((id, app_type, new_meta_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (id, app_type, new_meta) in updates {
|
||||
conn.execute(
|
||||
"UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![new_meta, id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表,统一 copilot 模板类型");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -980,6 +1059,14 @@ impl Database {
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-6-20260217",
|
||||
"Claude Sonnet 4.6",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// Claude 4.5 系列
|
||||
(
|
||||
"claude-opus-4-5-20251101",
|
||||
|
||||
@@ -296,6 +296,14 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE providers (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
settings_config TEXT NOT NULL DEFAULT '{}',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (id, app_type)
|
||||
);
|
||||
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
|
||||
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
|
||||
CREATE TABLE mcp_servers (
|
||||
@@ -488,6 +496,25 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
matches!(pending.as_deref(), Some("true") | Some("1")),
|
||||
"skills_ssot_migration_pending should be set after v2->v3 migration"
|
||||
);
|
||||
let snapshot: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_snapshot'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
let snapshot = snapshot.expect("skills migration snapshot should be recorded");
|
||||
let snapshot_rows: serde_json::Value =
|
||||
serde_json::from_str(&snapshot).expect("parse skills migration snapshot");
|
||||
assert!(
|
||||
snapshot_rows
|
||||
.as_array()
|
||||
.is_some_and(|rows| rows.iter().any(|row| {
|
||||
row.get("directory").and_then(|v| v.as_str()) == Some("demo-skill")
|
||||
&& row.get("app_type").and_then(|v| v.as_str()) == Some("claude")
|
||||
})),
|
||||
"skills migration snapshot should preserve legacy app mapping"
|
||||
);
|
||||
|
||||
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
|
||||
let proxy_rows: i64 = conn
|
||||
|
||||
+44
-1
@@ -25,10 +25,11 @@ mod services;
|
||||
mod session_manager;
|
||||
mod settings;
|
||||
mod store;
|
||||
|
||||
mod tray;
|
||||
mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
|
||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
@@ -44,6 +45,7 @@ pub use mcp::{
|
||||
};
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use services::{
|
||||
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||
SkillService, SpeedtestService,
|
||||
};
|
||||
@@ -688,6 +690,18 @@ pub fn run() {
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
|
||||
// 初始化 CopilotAuthManager
|
||||
{
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use commands::CopilotAuthState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let copilot_auth_manager = CopilotAuthManager::new(app_config_dir);
|
||||
app.manage(CopilotAuthState(Arc::new(RwLock::new(copilot_auth_manager))));
|
||||
log::info!("✓ CopilotAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
@@ -804,6 +818,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -917,8 +932,11 @@ pub fn run() {
|
||||
commands::restore_env_backup,
|
||||
// Skill management (v3.10.0+ unified)
|
||||
commands::get_installed_skills,
|
||||
commands::get_skill_backups,
|
||||
commands::delete_skill_backup,
|
||||
commands::install_skill_unified,
|
||||
commands::uninstall_skill_unified,
|
||||
commands::restore_skill_backup,
|
||||
commands::toggle_skill_app,
|
||||
commands::scan_unmanaged_skills,
|
||||
commands::import_skills_from_apps,
|
||||
@@ -1026,6 +1044,31 @@ pub fn run() {
|
||||
commands::scan_local_proxies,
|
||||
// Window theme control
|
||||
commands::set_window_theme,
|
||||
// Generic managed auth commands
|
||||
commands::auth_start_login,
|
||||
commands::auth_poll_for_account,
|
||||
commands::auth_list_accounts,
|
||||
commands::auth_get_status,
|
||||
commands::auth_remove_account,
|
||||
commands::auth_set_default_account,
|
||||
commands::auth_logout,
|
||||
// Copilot OAuth commands (multi-account support)
|
||||
commands::copilot_start_device_flow,
|
||||
commands::copilot_poll_for_auth,
|
||||
commands::copilot_poll_for_account,
|
||||
commands::copilot_list_accounts,
|
||||
commands::copilot_remove_account,
|
||||
commands::copilot_set_default_account,
|
||||
commands::copilot_get_auth_status,
|
||||
commands::copilot_logout,
|
||||
commands::copilot_is_authenticated,
|
||||
commands::copilot_get_token,
|
||||
commands::copilot_get_token_for_account,
|
||||
commands::copilot_get_models,
|
||||
commands::copilot_get_models_for_account,
|
||||
commands::copilot_get_usage,
|
||||
commands::copilot_get_usage_for_account,
|
||||
// OMO commands
|
||||
commands::read_omo_local_file,
|
||||
commands::get_current_omo_provider_id,
|
||||
commands::disable_current_omo,
|
||||
|
||||
@@ -294,7 +294,7 @@ impl OpenClawConfigDocument {
|
||||
|
||||
if let Some(existing) = key_value_pairs
|
||||
.iter_mut()
|
||||
.find(|pair| json5_key_name(&pair.key).as_deref() == Some(key))
|
||||
.find(|pair| json5_key_name(&pair.key) == Some(key))
|
||||
{
|
||||
existing.value = new_value;
|
||||
return Ok(());
|
||||
|
||||
@@ -191,6 +191,31 @@ pub struct ProviderProxyConfig {
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 认证绑定来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthBindingSource {
|
||||
/// 从 provider 自身配置读取认证信息(默认)
|
||||
#[default]
|
||||
ProviderConfig,
|
||||
/// 使用托管账号认证(如 GitHub Copilot OAuth)
|
||||
ManagedAccount,
|
||||
}
|
||||
|
||||
/// 通用认证绑定
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AuthBinding {
|
||||
/// 认证来源
|
||||
#[serde(default)]
|
||||
pub source: AuthBindingSource,
|
||||
/// 托管认证供应商标识(如 github_copilot)
|
||||
#[serde(rename = "authProvider", skip_serializing_if = "Option::is_none")]
|
||||
pub auth_provider: Option<String>,
|
||||
/// 托管账号 ID;为空表示跟随该认证供应商的默认账号
|
||||
#[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
@@ -242,14 +267,51 @@ pub struct ProviderMeta {
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
/// 通用认证绑定(provider_config / managed_account)
|
||||
///
|
||||
/// 新代码应只写入该字段;githubAccountId 仅保留兼容读取。
|
||||
#[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")]
|
||||
pub auth_binding: Option<AuthBinding>,
|
||||
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY")
|
||||
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_field: Option<String>,
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// 供应商类型标识(用于特殊供应商检测)
|
||||
/// - "github_copilot": GitHub Copilot 供应商
|
||||
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
|
||||
pub provider_type: Option<String>,
|
||||
/// GitHub Copilot 关联账号 ID(仅 github_copilot 供应商使用)
|
||||
/// 用于多账号支持,关联到特定的 GitHub 账号
|
||||
#[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
|
||||
pub github_account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderMeta {
|
||||
/// 解析指定托管认证供应商绑定的账号 ID。
|
||||
///
|
||||
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
||||
pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
|
||||
if let Some(binding) = self.auth_binding.as_ref() {
|
||||
if binding.source == AuthBindingSource::ManagedAccount
|
||||
&& binding.auth_provider.as_deref() == Some(auth_provider)
|
||||
{
|
||||
return binding.account_id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if auth_provider == "github_copilot" {
|
||||
return self.github_account_id.clone();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{
|
||||
failover_switch::FailoverSwitchManager,
|
||||
log_codes::fwd as log_fwd,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
@@ -16,10 +16,13 @@ use super::{
|
||||
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::commands::CopilotAuthState;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::Response;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
@@ -792,21 +795,38 @@ impl RequestForwarder {
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
|
||||
let effective_endpoint =
|
||||
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
||||
// 根据 api_format 选择目标端点
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
let (effective_endpoint, passthrough_query) =
|
||||
if needs_transform && adapter.name() == "Claude" {
|
||||
let api_format = super::providers::get_claude_api_format(provider);
|
||||
if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
}
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
|
||||
} else {
|
||||
endpoint
|
||||
(
|
||||
endpoint.to_string(),
|
||||
split_endpoint_and_query(endpoint)
|
||||
.1
|
||||
.map(ToString::to_string),
|
||||
)
|
||||
};
|
||||
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||
let url = if is_full_url {
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
} else {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
@@ -840,9 +860,22 @@ impl RequestForwarder {
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str();
|
||||
if HEADER_BLACKLIST
|
||||
.iter()
|
||||
.any(|h| key.as_str().eq_ignore_ascii_case(h))
|
||||
.any(|h| key_str.eq_ignore_ascii_case(h))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Copilot 请求:过滤会由 add_auth_headers 注入的固定指纹头,
|
||||
// 防止客户端原始头与注入头重复(reqwest header() 是追加语义)
|
||||
if is_copilot
|
||||
&& (key_str.eq_ignore_ascii_case("user-agent")
|
||||
|| key_str.eq_ignore_ascii_case("editor-version")
|
||||
|| key_str.eq_ignore_ascii_case("editor-plugin-version")
|
||||
|| key_str.eq_ignore_ascii_case("copilot-integration-id")
|
||||
|| key_str.eq_ignore_ascii_case("x-github-api-version")
|
||||
|| key_str.eq_ignore_ascii_case("openai-intent"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -885,12 +918,64 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用压缩,避免 gzip 流式响应解析错误
|
||||
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
|
||||
request = request.header("accept-encoding", "identity");
|
||||
// 流式请求保守禁用压缩,避免上游压缩 SSE 在连接中断时触发解压错误。
|
||||
// 非流式请求不显式设置 Accept-Encoding,让 reqwest 自动协商压缩并透明解压。
|
||||
if should_force_identity_encoding(&effective_endpoint, &filtered_body, headers) {
|
||||
request = request.header("accept-encoding", "identity");
|
||||
}
|
||||
|
||||
// 使用适配器添加认证头
|
||||
if let Some(auth) = adapter.extract_auth(provider) {
|
||||
if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
if auth.strategy == AuthStrategy::GitHubCopilot {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||
let copilot_auth: tokio::sync::RwLockReadGuard<'_, CopilotAuthManager> =
|
||||
copilot_state.0.read().await;
|
||||
|
||||
// 从 provider.meta 获取关联的 GitHub 账号 ID(多账号支持)
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("github_copilot"));
|
||||
|
||||
// 根据账号 ID 获取对应 token(向后兼容:无账号 ID 时使用第一个账号)
|
||||
let token_result = match &account_id {
|
||||
Some(id) => {
|
||||
log::debug!("[Copilot] 使用指定账号 {id} 获取 token");
|
||||
copilot_auth.get_valid_token_for_account(id).await
|
||||
}
|
||||
None => {
|
||||
log::debug!("[Copilot] 使用默认账号获取 token");
|
||||
copilot_auth.get_valid_token().await
|
||||
}
|
||||
};
|
||||
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
auth = AuthInfo::new(token, AuthStrategy::GitHubCopilot);
|
||||
log::debug!(
|
||||
"[Copilot] 成功获取 Copilot token (account={})",
|
||||
account_id.as_deref().unwrap_or("default")
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[Copilot] 获取 Copilot token 失败 (account={}): {e}",
|
||||
account_id.as_deref().unwrap_or("default")
|
||||
);
|
||||
return Err(ProxyError::AuthError(format!(
|
||||
"GitHub Copilot 认证失败: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("[Copilot] AppHandle 不可用");
|
||||
return Err(ProxyError::AuthError(
|
||||
"GitHub Copilot 认证不可用(无 AppHandle)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
request = adapter.add_auth_headers(request, &auth);
|
||||
}
|
||||
|
||||
@@ -1092,6 +1177,100 @@ fn extract_json_error_message(body: &Value) -> Option<String> {
|
||||
.find_map(|value| value.as_str().map(ToString::to_string))
|
||||
}
|
||||
|
||||
fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) {
|
||||
endpoint
|
||||
.split_once('?')
|
||||
.map_or((endpoint, None), |(path, query)| (path, Some(query)))
|
||||
}
|
||||
|
||||
fn strip_beta_query(query: Option<&str>) -> Option<String> {
|
||||
let filtered = query.map(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.filter(|pair| !pair.is_empty() && !pair.starts_with("beta="))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
});
|
||||
|
||||
match filtered.as_deref() {
|
||||
Some("") | None => None,
|
||||
Some(_) => filtered,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_claude_messages_path(path: &str) -> bool {
|
||||
matches!(path, "/v1/messages" | "/claude/v1/messages")
|
||||
}
|
||||
|
||||
fn rewrite_claude_transform_endpoint(
|
||||
endpoint: &str,
|
||||
api_format: &str,
|
||||
is_copilot: bool,
|
||||
) -> (String, Option<String>) {
|
||||
let (path, query) = split_endpoint_and_query(endpoint);
|
||||
let passthrough_query = if is_claude_messages_path(path) {
|
||||
strip_beta_query(query)
|
||||
} else {
|
||||
query.map(ToString::to_string)
|
||||
};
|
||||
|
||||
if !is_claude_messages_path(path) {
|
||||
return (endpoint.to_string(), passthrough_query);
|
||||
}
|
||||
|
||||
let target_path = if is_copilot {
|
||||
"/chat/completions"
|
||||
} else if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
};
|
||||
|
||||
let rewritten = match passthrough_query.as_deref() {
|
||||
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
|
||||
_ => target_path.to_string(),
|
||||
};
|
||||
|
||||
(rewritten, passthrough_query)
|
||||
}
|
||||
|
||||
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(query) if !query.is_empty() => {
|
||||
if base_url.contains('?') {
|
||||
format!("{base_url}&{query}")
|
||||
} else {
|
||||
format!("{base_url}?{query}")
|
||||
}
|
||||
}
|
||||
_ => base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_force_identity_encoding(
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> bool {
|
||||
if body
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if endpoint.contains("streamGenerateContent") || endpoint.contains("alt=sse") {
|
||||
return true;
|
||||
}
|
||||
|
||||
headers
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|accept| accept.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
@@ -1108,6 +1287,7 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{header::ACCEPT, HeaderMap, HeaderValue};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -1174,4 +1354,89 @@ mod tests {
|
||||
|
||||
assert_eq!(summary, "line1 line2...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&foo=bar",
|
||||
"openai_chat",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_beta_for_responses() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/claude/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
|
||||
let (endpoint, passthrough_query) =
|
||||
rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true);
|
||||
|
||||
assert_eq!(endpoint, "/chat/completions?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_to_full_url_preserves_existing_query_string() {
|
||||
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
|
||||
|
||||
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_for_stream_flag_requests() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert!(should_force_identity_encoding(
|
||||
"/v1/responses",
|
||||
&json!({ "stream": true }),
|
||||
&headers
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_for_gemini_stream_endpoints() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert!(should_force_identity_encoding(
|
||||
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
|
||||
&json!({ "model": "gemini-2.5-pro" }),
|
||||
&headers
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_for_sse_accept_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
|
||||
|
||||
assert!(should_force_identity_encoding(
|
||||
"/v1/responses",
|
||||
&json!({ "model": "gpt-5" }),
|
||||
&headers
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_streaming_requests_allow_automatic_compression() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert!(!should_force_identity_encoding(
|
||||
"/v1/responses",
|
||||
&json!({ "model": "gpt-5" }),
|
||||
&headers
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,18 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|path_and_query| path_and_query.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
@@ -77,7 +83,7 @@ pub async fn handle_messages(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -280,6 +286,13 @@ async fn handle_claude_transform(
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
|
||||
match uri.query() {
|
||||
Some(query) => format!("{endpoint}?{query}"),
|
||||
None => endpoint.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex API 处理器
|
||||
// ============================================================================
|
||||
@@ -287,11 +300,13 @@ async fn handle_claude_transform(
|
||||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||||
pub async fn handle_chat_completions(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/chat/completions");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -302,7 +317,7 @@ pub async fn handle_chat_completions(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/chat/completions",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -328,11 +343,13 @@ pub async fn handle_chat_completions(
|
||||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -343,7 +360,7 @@ pub async fn handle_responses(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
@@ -369,11 +386,13 @@ pub async fn handle_responses(
|
||||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -384,7 +403,7 @@ pub async fn handle_responses_compact(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses/compact",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
ctx.get_providers(),
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub(crate) mod sse;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
|
||||
@@ -112,6 +112,13 @@ pub enum AuthStrategy {
|
||||
///
|
||||
/// 用于 Gemini CLI 等需要 OAuth 的场景
|
||||
GoogleOAuth,
|
||||
|
||||
/// GitHub Copilot 认证方式
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <copilot_token>`
|
||||
///
|
||||
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
|
||||
GitHubCopilot,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -226,6 +233,7 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
AuthStrategy::Google,
|
||||
AuthStrategy::GoogleOAuth,
|
||||
AuthStrategy::GitHubCopilot,
|
||||
];
|
||||
|
||||
for (i, s1) in strategies.iter().enumerate() {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||||
//! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
@@ -76,10 +77,16 @@ impl ClaudeAdapter {
|
||||
/// 获取供应商类型
|
||||
///
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 GitHub Copilot
|
||||
if self.is_github_copilot(provider) {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
|
||||
// 检测 OpenRouter
|
||||
if self.is_openrouter(provider) {
|
||||
return ProviderType::OpenRouter;
|
||||
@@ -93,6 +100,25 @@ impl ClaudeAdapter {
|
||||
ProviderType::Claude
|
||||
}
|
||||
|
||||
/// 检测是否为 GitHub Copilot 供应商
|
||||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||||
// 方式1: 检查 meta.provider_type
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 方式2: 检查 base_url(兼容旧数据的 fallback,后续应优先依赖 providerType)
|
||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||
if base_url.contains("githubcopilot.com") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测是否使用 OpenRouter
|
||||
fn is_openrouter(&self, provider: &Provider) -> bool {
|
||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||
@@ -244,6 +270,17 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
let provider_type = self.provider_type(provider);
|
||||
|
||||
// GitHub Copilot 使用特殊的认证策略
|
||||
// 实际的 token 会在代理请求时动态获取
|
||||
if provider_type == ProviderType::GitHubCopilot {
|
||||
// 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供
|
||||
return Some(AuthInfo::new(
|
||||
"copilot_placeholder".to_string(),
|
||||
AuthStrategy::GitHubCopilot,
|
||||
));
|
||||
}
|
||||
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
@@ -261,7 +298,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
//
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
//
|
||||
let mut base = format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
@@ -273,19 +310,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base = base.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
|
||||
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions,
|
||||
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
|
||||
if endpoint.contains("/v1/messages")
|
||||
&& !endpoint.contains("/v1/chat/completions")
|
||||
&& !endpoint.contains('?')
|
||||
{
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
@@ -304,11 +329,25 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
// GitHub Copilot: Bearer + 统一指纹头
|
||||
AuthStrategy::GitHubCopilot => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION)
|
||||
.header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID)
|
||||
.header("user-agent", super::copilot_auth::COPILOT_USER_AGENT)
|
||||
.header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel"),
|
||||
_ => request,
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
// GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI)
|
||||
if self.is_github_copilot(provider) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
@@ -522,23 +561,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_other_endpoints() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 非 /v1/messages 端点不添加 ?beta=true
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||||
}
|
||||
@@ -546,16 +582,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_preserve_existing_query() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 已有查询参数时不重复添加
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_github_copilot() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let url = adapter.build_url("https://api.githubcopilot.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.githubcopilot.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// OpenAI Chat Completions 端点不添加 ?beta=true
|
||||
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
|
||||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||
}
|
||||
@@ -678,4 +718,67 @@ mod tests {
|
||||
);
|
||||
assert!(!adapter.needs_transform(&unknown_format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_detection_by_url() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// GitHub Copilot by base_url
|
||||
let copilot = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_detection_by_meta() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// GitHub Copilot by meta.provider_type
|
||||
let copilot_meta = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
adapter.provider_type(&copilot_meta),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_auth() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
let copilot = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&copilot).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
let copilot = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
|
||||
// GitHub Copilot always needs transform
|
||||
assert!(adapter.needs_transform(&copilot));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ mod adapter;
|
||||
mod auth;
|
||||
mod claude;
|
||||
mod codex;
|
||||
pub mod copilot_auth;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
@@ -52,6 +53,8 @@ pub enum ProviderType {
|
||||
GeminiCli,
|
||||
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
|
||||
OpenRouter,
|
||||
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
|
||||
GitHubCopilot,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
@@ -59,9 +62,11 @@ impl ProviderType {
|
||||
///
|
||||
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
|
||||
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
|
||||
/// GitHub Copilot 需要转换(Anthropic → OpenAI 格式)。
|
||||
#[allow(dead_code)]
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::GitHubCopilot => true,
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
@@ -77,6 +82,7 @@ impl ProviderType {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +93,20 @@ impl ProviderType {
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
// 检测是否为 OpenRouter
|
||||
// 检测是否为 GitHub Copilot
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 base_url 是否为 GitHub Copilot
|
||||
let adapter = ClaudeAdapter::new();
|
||||
if let Ok(base_url) = adapter.extract_base_url(provider) {
|
||||
if base_url.contains("githubcopilot.com") {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
// 检测是否为 OpenRouter
|
||||
if base_url.contains("openrouter.ai") {
|
||||
return ProviderType::OpenRouter;
|
||||
}
|
||||
@@ -154,6 +171,7 @@ impl ProviderType {
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
ProviderType::GitHubCopilot => "github_copilot",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,6 +193,9 @@ impl std::str::FromStr for ProviderType {
|
||||
"gemini" => Ok(ProviderType::Gemini),
|
||||
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
|
||||
"openrouter" => Ok(ProviderType::OpenRouter),
|
||||
"github_copilot" | "github-copilot" | "githubcopilot" => {
|
||||
Ok(ProviderType::GitHubCopilot)
|
||||
}
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -201,9 +222,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
#[allow(dead_code)]
|
||||
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
|
||||
match provider_type {
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
|
||||
Box::new(ClaudeAdapter::new())
|
||||
}
|
||||
ProviderType::Claude
|
||||
| ProviderType::ClaudeAuth
|
||||
| ProviderType::OpenRouter
|
||||
| ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
@@ -239,6 +261,7 @@ mod tests {
|
||||
assert!(!ProviderType::Gemini.needs_transform());
|
||||
assert!(!ProviderType::GeminiCli.needs_transform());
|
||||
assert!(!ProviderType::OpenRouter.needs_transform());
|
||||
assert!(ProviderType::GitHubCopilot.needs_transform());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -267,6 +290,10 @@ mod tests {
|
||||
ProviderType::OpenRouter.default_endpoint(),
|
||||
"https://openrouter.ai/api"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::GitHubCopilot.default_endpoint(),
|
||||
"https://api.githubcopilot.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -303,6 +330,18 @@ mod tests {
|
||||
"openrouter".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::OpenRouter
|
||||
);
|
||||
assert_eq!(
|
||||
"github_copilot".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
assert_eq!(
|
||||
"github-copilot".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
assert_eq!(
|
||||
"githubcopilot".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
assert!("invalid".parse::<ProviderType>().is_err());
|
||||
}
|
||||
|
||||
@@ -314,6 +353,7 @@ mod tests {
|
||||
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
|
||||
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
|
||||
assert_eq!(ProviderType::GitHubCopilot.as_str(), "github_copilot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -434,6 +474,9 @@ mod tests {
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::GitHubCopilot);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
|
||||
assert_eq!(adapter.name(), "Codex");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
|
||||
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -118,7 +119,7 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
|
||||
for l in line.lines() {
|
||||
if let Some(data) = l.strip_prefix("data: ") {
|
||||
if let Some(data) = strip_sse_field(l, "data") {
|
||||
if data.trim() == "[DONE]" {
|
||||
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
let event = json!({"type": "message_stop"});
|
||||
@@ -609,7 +610,9 @@ mod tests {
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
@@ -694,7 +697,9 @@ mod tests {
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
|
||||
|
||||
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
@@ -133,9 +134,9 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
let mut data_parts: Vec<String> = Vec::new();
|
||||
|
||||
for line in block.lines() {
|
||||
if let Some(evt) = line.strip_prefix("event: ") {
|
||||
if let Some(evt) = strip_sse_field(line, "event") {
|
||||
event_type = Some(evt.trim().to_string());
|
||||
} else if let Some(d) = line.strip_prefix("data: ") {
|
||||
} else if let Some(d) = strip_sse_field(line, "data") {
|
||||
data_parts.push(d.to_string());
|
||||
}
|
||||
}
|
||||
@@ -810,7 +811,9 @@ mod tests {
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -6,6 +6,71 @@
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Detect OpenAI o-series reasoning models (o1, o3, o4-mini, etc.)
|
||||
/// These models require `max_completion_tokens` instead of `max_tokens`.
|
||||
pub fn is_openai_o_series(model: &str) -> bool {
|
||||
model.len() > 1
|
||||
&& model.starts_with('o')
|
||||
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Detect OpenAI models that support reasoning_effort.
|
||||
///
|
||||
/// Supported families:
|
||||
/// - o-series: o1, o3, o4-mini, etc.
|
||||
/// - GPT-5+: gpt-5, gpt-5.1, gpt-5.4, gpt-5-codex, etc.
|
||||
pub fn supports_reasoning_effort(model: &str) -> bool {
|
||||
is_openai_o_series(model)
|
||||
|| model
|
||||
.to_lowercase()
|
||||
.strip_prefix("gpt-")
|
||||
.and_then(|rest| rest.chars().next())
|
||||
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
|
||||
}
|
||||
|
||||
/// Resolve the appropriate OpenAI `reasoning_effort` from an Anthropic request body.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Explicit `output_config.effort` — preserves the user's intent directly.
|
||||
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
|
||||
/// (supported by mainstream GPT models). Unknown values are ignored.
|
||||
/// 2. Fallback: `thinking.type` + `budget_tokens`:
|
||||
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
|
||||
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 000–15 999) / `high` (≥16 000)
|
||||
/// - `enabled` without budget → `high` (conservative default)
|
||||
/// - `disabled` / absent → `None`
|
||||
pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
// --- Priority 1: explicit output_config.effort ---
|
||||
if let Some(effort) = body
|
||||
.pointer("/output_config/effort")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return match effort {
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
|
||||
_ => None, // unknown value — do not inject
|
||||
};
|
||||
}
|
||||
|
||||
// --- Priority 2: thinking.type + budget_tokens fallback ---
|
||||
let thinking = body.get("thinking")?;
|
||||
match thinking.get("type").and_then(|t| t.as_str()) {
|
||||
Some("adaptive") => Some("high"),
|
||||
Some("enabled") => {
|
||||
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
|
||||
match budget {
|
||||
Some(b) if b < 4_000 => Some("low"),
|
||||
Some(b) if b < 16_000 => Some("medium"),
|
||||
Some(_) => Some("high"),
|
||||
None => Some("high"), // enabled but no budget — assume strong reasoning
|
||||
}
|
||||
}
|
||||
_ => None, // disabled or missing
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
@@ -50,9 +115,14 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数
|
||||
// 转换参数 — o-series 模型需要 max_completion_tokens
|
||||
let model = body.get("model").and_then(|m| m.as_str()).unwrap_or("");
|
||||
if let Some(v) = body.get("max_tokens") {
|
||||
result["max_tokens"] = v.clone();
|
||||
if is_openai_o_series(model) {
|
||||
result["max_completion_tokens"] = v.clone();
|
||||
} else {
|
||||
result["max_tokens"] = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = body.get("temperature") {
|
||||
result["temperature"] = v.clone();
|
||||
@@ -67,6 +137,13 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
result["stream"] = v.clone();
|
||||
}
|
||||
|
||||
// Map Anthropic thinking → OpenAI reasoning_effort
|
||||
if supports_reasoning_effort(model) {
|
||||
if let Some(effort) = resolve_reasoning_effort(&body) {
|
||||
result["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 tools (过滤 BatchTool)
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
let openai_tools: Vec<Value> = tools
|
||||
@@ -772,4 +849,226 @@ mod tests {
|
||||
assert_eq!(result["content"][1]["type"], "text");
|
||||
assert_eq!(result["content"][1]["text"], "I can't do that");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_openai_o_series() {
|
||||
assert!(is_openai_o_series("o1"));
|
||||
assert!(is_openai_o_series("o1-preview"));
|
||||
assert!(is_openai_o_series("o1-mini"));
|
||||
assert!(is_openai_o_series("o3"));
|
||||
assert!(is_openai_o_series("o3-mini"));
|
||||
assert!(is_openai_o_series("o4-mini"));
|
||||
assert!(!is_openai_o_series("gpt-4o"));
|
||||
assert!(!is_openai_o_series("openai-gpt"));
|
||||
assert!(!is_openai_o_series("o"));
|
||||
assert!(!is_openai_o_series(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_reasoning_effort() {
|
||||
assert!(supports_reasoning_effort("o1"));
|
||||
assert!(supports_reasoning_effort("o3-mini"));
|
||||
assert!(supports_reasoning_effort("gpt-5"));
|
||||
assert!(supports_reasoning_effort("gpt-5.4"));
|
||||
assert!(supports_reasoning_effort("gpt-5-codex"));
|
||||
assert!(!supports_reasoning_effort("gpt-4o"));
|
||||
assert!(!supports_reasoning_effort("claude-sonnet-4-6"));
|
||||
}
|
||||
|
||||
// ── resolve_reasoning_effort unit tests ──
|
||||
|
||||
#[test]
|
||||
fn test_output_config_low_maps_to_reasoning_effort_low() {
|
||||
let body = json!({"output_config": {"effort": "low"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_medium_maps_to_reasoning_effort_medium() {
|
||||
let body = json!({"output_config": {"effort": "medium"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_high_maps_to_reasoning_effort_high() {
|
||||
let body = json!({"output_config": {"effort": "high"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_max_maps_to_reasoning_effort_xhigh() {
|
||||
let body = json!({"output_config": {"effort": "max"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_takes_priority_over_thinking() {
|
||||
// Even with thinking.adaptive present, explicit effort wins
|
||||
let body = json!({
|
||||
"output_config": {"effort": "low"},
|
||||
"thinking": {"type": "adaptive"}
|
||||
});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_unknown_value_no_reasoning_effort() {
|
||||
let body = json!({"output_config": {"effort": "turbo"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_small_budget_maps_low() {
|
||||
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 1024}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_medium_budget_maps_medium() {
|
||||
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 8000}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_large_budget_maps_high() {
|
||||
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 32000}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_without_budget_maps_high() {
|
||||
let body = json!({"thinking": {"type": "enabled"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive_maps_high() {
|
||||
let body = json!({"thinking": {"type": "adaptive"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_disabled_no_reasoning_effort() {
|
||||
let body = json!({"thinking": {"type": "disabled"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_thinking_field_no_reasoning_effort() {
|
||||
let body = json!({"messages": [{"role": "user", "content": "Hello"}]});
|
||||
assert_eq!(resolve_reasoning_effort(&body), None);
|
||||
}
|
||||
|
||||
// ── Integration: anthropic_to_openai with resolve_reasoning_effort ──
|
||||
|
||||
#[test]
|
||||
fn test_non_reasoning_model_no_reasoning_effort() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_with_output_config_effort() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"output_config": {"effort": "medium"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_with_output_config_max() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"output_config": {"effort": "max"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_thinking_enabled_small_budget() {
|
||||
let input = json!({
|
||||
"model": "o3",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_thinking_adaptive() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "adaptive"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_no_thinking_no_effort() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_o_series_max_completion_tokens() {
|
||||
for model in &["o1", "o3-mini", "o4-mini"] {
|
||||
let input = json!({
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(
|
||||
result.get("max_tokens").is_none(),
|
||||
"{model} should not have max_tokens"
|
||||
);
|
||||
assert_eq!(
|
||||
result["max_completion_tokens"], 4096,
|
||||
"{model} should use max_completion_tokens"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_non_o_series_keeps_max_tokens() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
|
||||
result["input"] = json!(input);
|
||||
}
|
||||
|
||||
// max_tokens → max_output_tokens
|
||||
// max_tokens → max_output_tokens (Responses API uses max_output_tokens for all models)
|
||||
if let Some(v) = body.get("max_tokens") {
|
||||
result["max_output_tokens"] = v.clone();
|
||||
}
|
||||
@@ -61,6 +61,15 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
|
||||
result["stream"] = v.clone();
|
||||
}
|
||||
|
||||
// Map Anthropic thinking → OpenAI Responses reasoning.effort
|
||||
if let Some(model_name) = body.get("model").and_then(|m| m.as_str()) {
|
||||
if super::transform::supports_reasoning_effort(model_name) {
|
||||
if let Some(effort) = super::transform::resolve_reasoning_effort(&body) {
|
||||
result["reasoning"] = json!({ "effort": effort });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop_sequences → 丢弃 (Responses API 不支持)
|
||||
|
||||
// 转换 tools (过滤 BatchTool)
|
||||
@@ -897,4 +906,109 @@ mod tests {
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_o_series_uses_max_output_tokens() {
|
||||
// Responses API always uses max_output_tokens, even for o-series models
|
||||
let input = json!({
|
||||
"model": "o3-mini",
|
||||
"max_tokens": 4096,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["max_output_tokens"], 4096);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_output_config_max_sets_reasoning_xhigh() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"output_config": {"effort": "max"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_output_config_takes_priority_over_thinking() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"output_config": {"effort": "low"},
|
||||
"thinking": {"type": "adaptive"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_thinking_enabled_small_budget_sets_reasoning_low() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_thinking_enabled_medium_budget_sets_reasoning_medium() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 8000},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_thinking_enabled_large_budget_sets_reasoning_high() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 32000},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_thinking_adaptive_sets_reasoning_high() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "adaptive"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_non_reasoning_model_no_reasoning() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None).unwrap();
|
||||
assert!(result.get("reasoning").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::session::ProxySession;
|
||||
use super::usage::parser::TokenUsage;
|
||||
use super::ProxyError;
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
@@ -90,7 +91,7 @@ impl StreamHandler {
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||||
let mut guard = events.lock().await;
|
||||
@@ -211,4 +212,24 @@ mod tests {
|
||||
let handler = StreamHandler::new(30);
|
||||
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data: {\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data:{\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event: message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event:message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
handler_config::UsageParserConfig,
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
server::ProxyState,
|
||||
sse::strip_sse_field,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
@@ -527,7 +528,7 @@ pub fn create_logged_passthrough_stream(
|
||||
if !event_text.trim().is_empty() {
|
||||
// 提取 data 部分并尝试解析为 JSON
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
|
||||
if let Some(c) = &collector {
|
||||
@@ -591,6 +592,27 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data: {\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data:{\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event: message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event:message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(super::strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||
ProxyState {
|
||||
db: db.clone(),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#[inline]
|
||||
pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str> {
|
||||
line.strip_prefix(&format!("{field}: "))
|
||||
.or_else(|| line.strip_prefix(&format!("{field}:")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_sse_field;
|
||||
|
||||
#[test]
|
||||
fn strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
strip_sse_field("data: {\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_sse_field("data:{\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_sse_field("event: message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_sse_field("event:message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,18 @@ impl McpService {
|
||||
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
|
||||
let servers = Self::get_all_servers(state)?;
|
||||
|
||||
for server in servers.values() {
|
||||
Self::sync_server_to_apps(state, server)?;
|
||||
for app in AppType::all() {
|
||||
if matches!(app, AppType::OpenClaw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for server in servers.values() {
|
||||
if server.apps.is_enabled_for(&app) {
|
||||
Self::sync_server_to_app(state, server, &app)?;
|
||||
} else {
|
||||
Self::remove_server_from_app(state, &server.id, &app)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+435
-13
@@ -152,6 +152,33 @@ impl Default for SkillStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skill 卸载结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillUninstallResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backup_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillBackupEntry {
|
||||
pub backup_id: String,
|
||||
pub backup_path: String,
|
||||
pub created_at: i64,
|
||||
pub skill: InstalledSkill,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkillBackupMetadata {
|
||||
skill: InstalledSkill,
|
||||
backup_created_at: i64,
|
||||
source_path: String,
|
||||
}
|
||||
|
||||
const SKILL_BACKUP_RETAIN_COUNT: usize = 20;
|
||||
|
||||
/// 技能元数据 (从 SKILL.md 解析)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SkillMetadata {
|
||||
@@ -159,6 +186,21 @@ pub struct SkillMetadata {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 导入已有 Skill 时,前端显式提交的启用应用选择
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportSkillSelection {
|
||||
pub directory: String,
|
||||
#[serde(default)]
|
||||
pub apps: SkillApps,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct LegacySkillMigrationRow {
|
||||
directory: String,
|
||||
app_type: String,
|
||||
}
|
||||
|
||||
// ========== ~/.agents/ lock 文件解析 ==========
|
||||
|
||||
/// `~/.agents/.skill-lock.json` 文件结构
|
||||
@@ -354,6 +396,13 @@ impl SkillService {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 获取 Skill 卸载备份目录(~/.cc-switch/skill-backups/)
|
||||
fn get_backup_dir() -> Result<PathBuf> {
|
||||
let dir = get_app_config_dir().join("skill-backups");
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 获取应用的 skills 目录
|
||||
pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf> {
|
||||
// 目录覆盖:优先使用用户在 settings.json 中配置的 override 目录
|
||||
@@ -621,12 +670,15 @@ impl SkillService {
|
||||
/// 1. 从所有应用目录删除
|
||||
/// 2. 从 SSOT 删除
|
||||
/// 3. 从数据库删除
|
||||
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<()> {
|
||||
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
|
||||
// 获取 skill 信息
|
||||
let skill = db
|
||||
.get_installed_skill(id)?
|
||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||
|
||||
let backup_path =
|
||||
Self::create_uninstall_backup(&skill)?.map(|path| path.to_string_lossy().to_string());
|
||||
|
||||
// 从所有应用目录删除
|
||||
for app in AppType::all() {
|
||||
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||
@@ -642,11 +694,137 @@ impl SkillService {
|
||||
// 从数据库删除
|
||||
db.delete_skill(id)?;
|
||||
|
||||
log::info!("Skill {} 卸载成功", skill.name);
|
||||
log::info!(
|
||||
"Skill {} 卸载成功{}",
|
||||
skill.name,
|
||||
backup_path
|
||||
.as_deref()
|
||||
.map(|path| format!(", backup: {path}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
Ok(SkillUninstallResult { backup_path })
|
||||
}
|
||||
|
||||
pub fn list_backups() -> Result<Vec<SkillBackupEntry>> {
|
||||
let backup_dir = Self::get_backup_dir()?;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&backup_dir)? {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
log::warn!("读取 Skill 备份目录项失败: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match Self::read_backup_metadata(&path) {
|
||||
Ok(metadata) => entries.push(SkillBackupEntry {
|
||||
backup_id: entry.file_name().to_string_lossy().to_string(),
|
||||
backup_path: path.to_string_lossy().to_string(),
|
||||
created_at: metadata.backup_created_at,
|
||||
skill: metadata.skill,
|
||||
}),
|
||||
Err(err) => {
|
||||
log::warn!("解析 Skill 备份失败 {}: {err:#}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn delete_backup(backup_id: &str) -> Result<()> {
|
||||
let backup_path = Self::backup_path_for_id(backup_id)?;
|
||||
let metadata = fs::symlink_metadata(&backup_path)
|
||||
.with_context(|| format!("failed to access {}", backup_path.display()))?;
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return Err(anyhow!(
|
||||
"Skill backup is not a directory: {}",
|
||||
backup_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&backup_path)
|
||||
.with_context(|| format!("failed to delete {}", backup_path.display()))?;
|
||||
|
||||
log::info!("Skill 备份已删除: {}", backup_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restore_from_backup(
|
||||
db: &Arc<Database>,
|
||||
backup_id: &str,
|
||||
current_app: &AppType,
|
||||
) -> Result<InstalledSkill> {
|
||||
let backup_path = Self::backup_path_for_id(backup_id)?;
|
||||
let metadata = Self::read_backup_metadata(&backup_path)?;
|
||||
let backup_skill_dir = backup_path.join("skill");
|
||||
if !backup_skill_dir.join("SKILL.md").exists() {
|
||||
return Err(anyhow!(
|
||||
"Skill backup is invalid or missing SKILL.md: {}",
|
||||
backup_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let existing_skills = db.get_all_installed_skills()?;
|
||||
if existing_skills.contains_key(&metadata.skill.id)
|
||||
|| existing_skills.values().any(|skill| {
|
||||
skill
|
||||
.directory
|
||||
.eq_ignore_ascii_case(&metadata.skill.directory)
|
||||
})
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Skill already exists, please uninstall the current one first: {}",
|
||||
metadata.skill.directory
|
||||
));
|
||||
}
|
||||
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let restore_path = ssot_dir.join(&metadata.skill.directory);
|
||||
if restore_path.exists() || Self::is_symlink(&restore_path) {
|
||||
return Err(anyhow!(
|
||||
"Restore target already exists: {}",
|
||||
restore_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut restored_skill = metadata.skill;
|
||||
restored_skill.installed_at = Utc::now().timestamp();
|
||||
restored_skill.apps = SkillApps::only(current_app);
|
||||
|
||||
Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?;
|
||||
|
||||
if let Err(err) = db.save_skill(&restored_skill) {
|
||||
let _ = fs::remove_dir_all(&restore_path);
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
if !restored_skill.apps.is_empty() {
|
||||
if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {
|
||||
let _ = db.delete_skill(&restored_skill.id);
|
||||
let _ = fs::remove_dir_all(&restore_path);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Skill {} 已从备份恢复到 {}",
|
||||
restored_skill.name,
|
||||
restore_path.display()
|
||||
);
|
||||
|
||||
Ok(restored_skill)
|
||||
}
|
||||
|
||||
/// 切换应用启用状态
|
||||
///
|
||||
/// 启用:复制到应用目录
|
||||
@@ -717,6 +895,9 @@ impl SkillService {
|
||||
}
|
||||
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||
|
||||
unmanaged
|
||||
@@ -740,14 +921,18 @@ impl SkillService {
|
||||
/// 将未管理的 Skills 导入到 CC Switch 统一管理
|
||||
pub fn import_from_apps(
|
||||
db: &Arc<Database>,
|
||||
directories: Vec<String>,
|
||||
imports: Vec<ImportSkillSelection>,
|
||||
) -> Result<Vec<InstalledSkill>> {
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let agents_lock = parse_agents_lock();
|
||||
let mut imported = Vec::new();
|
||||
|
||||
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||||
save_repos_from_lock(db, &agents_lock, directories.iter().map(|s| s.as_str()));
|
||||
save_repos_from_lock(
|
||||
db,
|
||||
&agents_lock,
|
||||
imports.iter().map(|selection| selection.directory.as_str()),
|
||||
);
|
||||
|
||||
// 收集所有候选搜索目录
|
||||
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
|
||||
@@ -761,10 +946,10 @@ impl SkillService {
|
||||
}
|
||||
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
|
||||
|
||||
for dir_name in directories {
|
||||
for selection in imports {
|
||||
let dir_name = selection.directory;
|
||||
// 在所有候选目录中查找
|
||||
let mut source_path: Option<PathBuf> = None;
|
||||
let mut found_in: Vec<String> = Vec::new();
|
||||
|
||||
for (base, label) in &search_sources {
|
||||
let skill_path = base.join(&dir_name);
|
||||
@@ -772,7 +957,7 @@ impl SkillService {
|
||||
if source_path.is_none() {
|
||||
source_path = Some(skill_path);
|
||||
}
|
||||
found_in.push(label.clone());
|
||||
log::debug!("Skill '{}' found in source '{}'", dir_name, label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,6 +965,14 @@ impl SkillService {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
if !source.join("SKILL.md").exists() {
|
||||
log::warn!(
|
||||
"Skip importing '{}' because source '{}' has no SKILL.md",
|
||||
dir_name,
|
||||
source.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 复制到 SSOT
|
||||
let dest = ssot_dir.join(&dir_name);
|
||||
@@ -791,8 +984,8 @@ impl SkillService {
|
||||
let skill_md = dest.join("SKILL.md");
|
||||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||
|
||||
// 构建启用状态
|
||||
let apps = SkillApps::from_labels(&found_in);
|
||||
// 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。
|
||||
let apps = selection.apps;
|
||||
|
||||
// 从 lock 文件提取仓库信息
|
||||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||
@@ -935,6 +1128,33 @@ impl SkillService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 判断路径是否为指向 SSOT 目录内的符号链接。
|
||||
fn is_symlink_to_ssot(path: &Path, ssot_dir: &Path) -> bool {
|
||||
if !Self::is_symlink(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(target) = fs::read_link(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if target.is_absolute() && target.starts_with(ssot_dir) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let resolved = path
|
||||
.parent()
|
||||
.map(|parent| parent.join(&target))
|
||||
.unwrap_or(target.clone());
|
||||
|
||||
let canonical_ssot = ssot_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| ssot_dir.to_path_buf());
|
||||
let canonical_target = resolved.canonicalize().unwrap_or(resolved);
|
||||
|
||||
canonical_target.starts_with(&canonical_ssot)
|
||||
}
|
||||
|
||||
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||||
let app_dir = Self::get_app_skills_dir(app)?;
|
||||
@@ -951,6 +1171,36 @@ impl SkillService {
|
||||
/// 同步所有已启用的 Skills 到指定应用
|
||||
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
|
||||
let skills = db.get_all_installed_skills()?;
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let app_dir = Self::get_app_skills_dir(app)?;
|
||||
|
||||
let indexed_skills: HashMap<String, &InstalledSkill> = skills
|
||||
.values()
|
||||
.map(|skill| (skill.directory.to_lowercase(), skill))
|
||||
.collect();
|
||||
|
||||
if app_dir.exists() {
|
||||
for entry in fs::read_dir(&app_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if dir_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(skill) = indexed_skills.get(&dir_name.to_lowercase()) {
|
||||
if !skill.apps.is_enabled_for(app) {
|
||||
Self::remove_path(&path)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if Self::is_symlink_to_ssot(&path, &ssot_dir) {
|
||||
Self::remove_path(&path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for skill in skills.values() {
|
||||
if skill.apps.is_enabled_for(app) {
|
||||
@@ -1413,6 +1663,144 @@ impl SkillService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_uninstall_backup_source(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
|
||||
let ssot_path = Self::get_ssot_dir()?.join(&skill.directory);
|
||||
if ssot_path.is_dir() {
|
||||
return Ok(Some(ssot_path));
|
||||
}
|
||||
|
||||
for app in AppType::all() {
|
||||
let app_dir = match Self::get_app_skills_dir(&app) {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let candidate = app_dir.join(&skill.directory);
|
||||
if candidate.is_dir() {
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn sanitize_backup_segment(segment: &str) -> String {
|
||||
let sanitized = segment
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
|
||||
if sanitized.is_empty() {
|
||||
"skill".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_old_skill_backups(dir: &Path) -> Result<()> {
|
||||
let mut entries = fs::read_dir(dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| {
|
||||
let metadata = entry.metadata().ok()?;
|
||||
if !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
Some((entry.path(), metadata.modified().ok()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if entries.len() <= SKILL_BACKUP_RETAIN_COUNT {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
entries.sort_by_key(|(_, modified)| *modified);
|
||||
let remove_count = entries.len().saturating_sub(SKILL_BACKUP_RETAIN_COUNT);
|
||||
|
||||
for (path, _) in entries.into_iter().take(remove_count) {
|
||||
fs::remove_dir_all(&path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backup_path_for_id(backup_id: &str) -> Result<PathBuf> {
|
||||
if backup_id.contains("..")
|
||||
|| backup_id.contains('/')
|
||||
|| backup_id.contains('\\')
|
||||
|| backup_id.trim().is_empty()
|
||||
{
|
||||
return Err(anyhow!("Invalid backup id: {backup_id}"));
|
||||
}
|
||||
|
||||
Ok(Self::get_backup_dir()?.join(backup_id))
|
||||
}
|
||||
|
||||
fn read_backup_metadata(backup_path: &Path) -> Result<SkillBackupMetadata> {
|
||||
let metadata_path = backup_path.join("meta.json");
|
||||
let content = fs::read_to_string(&metadata_path)
|
||||
.with_context(|| format!("failed to read {}", metadata_path.display()))?;
|
||||
serde_json::from_str(&content)
|
||||
.with_context(|| format!("failed to parse {}", metadata_path.display()))
|
||||
}
|
||||
|
||||
fn create_uninstall_backup(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
|
||||
let Some(source_path) = Self::resolve_uninstall_backup_source(skill)? else {
|
||||
log::warn!(
|
||||
"Skill {} 卸载前未找到可备份的目录,将跳过备份",
|
||||
skill.directory
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let backup_root = Self::get_backup_dir()?;
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let slug = Self::sanitize_backup_segment(&skill.directory);
|
||||
let mut backup_path = backup_root.join(format!("{timestamp}_{slug}"));
|
||||
let mut counter = 1;
|
||||
while backup_path.exists() {
|
||||
backup_path = backup_root.join(format!("{timestamp}_{slug}_{counter}"));
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
let write_backup = || -> Result<()> {
|
||||
let skill_backup_dir = backup_path.join("skill");
|
||||
Self::copy_dir_recursive(&source_path, &skill_backup_dir)?;
|
||||
|
||||
let metadata = SkillBackupMetadata {
|
||||
skill: skill.clone(),
|
||||
backup_created_at: Utc::now().timestamp(),
|
||||
source_path: source_path.to_string_lossy().to_string(),
|
||||
};
|
||||
let metadata_path = backup_path.join("meta.json");
|
||||
let metadata_json = serde_json::to_string_pretty(&metadata)
|
||||
.context("failed to serialize skill backup metadata")?;
|
||||
fs::write(&metadata_path, metadata_json)
|
||||
.with_context(|| format!("failed to write {}", metadata_path.display()))?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Err(err) = write_backup() {
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Err(err) = Self::cleanup_old_skill_backups(&backup_root) {
|
||||
log::warn!("清理旧 Skill 备份失败: {err:#}");
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Skill {} 已在卸载前备份到 {}",
|
||||
skill.name,
|
||||
backup_path.display()
|
||||
);
|
||||
|
||||
Ok(Some(backup_path))
|
||||
}
|
||||
|
||||
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
|
||||
///
|
||||
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
|
||||
@@ -1814,8 +2202,32 @@ fn save_repos_from_lock(
|
||||
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
let ssot_dir = SkillService::get_ssot_dir()?;
|
||||
let agents_lock = parse_agents_lock();
|
||||
let snapshot: Vec<LegacySkillMigrationRow> =
|
||||
match db.get_setting("skills_ssot_migration_snapshot")? {
|
||||
Some(value) if !value.trim().is_empty() => match serde_json::from_str(&value) {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
log::warn!("解析 skills 迁移快照失败,将回退到文件系统扫描: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let has_snapshot = !snapshot.is_empty();
|
||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||||
|
||||
if has_snapshot {
|
||||
for row in &snapshot {
|
||||
if let Ok(app) = row.app_type.parse::<AppType>() {
|
||||
discovered
|
||||
.entry(row.directory.clone())
|
||||
.or_default()
|
||||
.set_enabled_for(&app, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描各应用目录
|
||||
for app in AppType::all() {
|
||||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||
@@ -1838,6 +2250,12 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
if dir_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if !path.join("SKILL.md").exists() {
|
||||
continue;
|
||||
}
|
||||
if has_snapshot && !discovered.contains_key(&dir_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 复制到 SSOT(如果不存在)
|
||||
let ssot_path = ssot_dir.join(&dir_name);
|
||||
@@ -1845,10 +2263,12 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
SkillService::copy_dir_recursive(&path, &ssot_path)?;
|
||||
}
|
||||
|
||||
discovered
|
||||
.entry(dir_name)
|
||||
.or_default()
|
||||
.set_enabled_for(&app, true);
|
||||
if !has_snapshot {
|
||||
discovered
|
||||
.entry(dir_name)
|
||||
.or_default()
|
||||
.set_enabled_for(&app, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1885,6 +2305,8 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
let _ = db.set_setting("skills_ssot_migration_snapshot", "");
|
||||
|
||||
log::info!("Skills 迁移完成,共 {count} 个");
|
||||
|
||||
Ok(count)
|
||||
|
||||
@@ -12,6 +12,9 @@ use std::time::Instant;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::transform::anthropic_to_openai;
|
||||
use crate::proxy::providers::copilot_auth;
|
||||
use crate::proxy::providers::transform_responses::anthropic_to_responses;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||
|
||||
/// 健康状态枚举
|
||||
@@ -84,13 +87,16 @@ impl StreamCheckService {
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
// 合并供应商单独配置和全局配置
|
||||
let effective_config = Self::merge_provider_config(provider, config);
|
||||
let mut last_result = None;
|
||||
|
||||
for attempt in 0..=effective_config.max_retries {
|
||||
let result = Self::check_once(app_type, provider, &effective_config).await;
|
||||
let result =
|
||||
Self::check_once(app_type, provider, &effective_config, auth_override.clone())
|
||||
.await;
|
||||
|
||||
match &result {
|
||||
Ok(r) if r.success => {
|
||||
@@ -178,6 +184,7 @@ impl StreamCheckService {
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
let start = Instant::now();
|
||||
let adapter = get_adapter(app_type);
|
||||
@@ -186,8 +193,8 @@ impl StreamCheckService {
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
||||
|
||||
let auth = adapter
|
||||
.extract_auth(provider)
|
||||
let auth = auth_override
|
||||
.or_else(|| adapter.extract_auth(provider))
|
||||
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
|
||||
|
||||
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||
@@ -219,6 +226,7 @@ impl StreamCheckService {
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -297,6 +305,7 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
|
||||
|
||||
// Detect api_format: meta.api_format > settings_config.api_format > default "anthropic"
|
||||
let api_format = provider
|
||||
@@ -311,40 +320,53 @@ impl StreamCheckService {
|
||||
})
|
||||
.unwrap_or("anthropic");
|
||||
|
||||
let is_openai_chat = api_format == "openai_chat";
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
let is_openai_chat = is_github_copilot || api_format == "openai_chat";
|
||||
let is_openai_responses = !is_github_copilot && api_format == "openai_responses";
|
||||
let url = Self::resolve_claude_stream_url(base, auth.strategy, api_format, is_full_url);
|
||||
|
||||
// URL: /v1/chat/completions for openai_chat, /v1/messages?beta=true for anthropic
|
||||
let url = if is_openai_chat {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
}
|
||||
} else {
|
||||
// ?beta=true is required by some relay services to verify request origin
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/messages?beta=true")
|
||||
} else {
|
||||
format!("{base}/v1/messages?beta=true")
|
||||
}
|
||||
};
|
||||
|
||||
// Body: identical structure for minimal test (both APIs accept messages array)
|
||||
let body = json!({
|
||||
// Build from Anthropic-native shape first, then convert for configured targets.
|
||||
let anthropic_body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id))
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_openai_chat {
|
||||
anthropic_to_openai(anthropic_body, Some(&provider.id))
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else {
|
||||
anthropic_body
|
||||
};
|
||||
|
||||
let mut request_builder = client.post(&url);
|
||||
|
||||
if is_openai_chat {
|
||||
// OpenAI-compatible: Bearer auth + standard headers only
|
||||
if is_github_copilot {
|
||||
request_builder = request_builder
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json");
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.header("user-agent", copilot_auth::COPILOT_USER_AGENT)
|
||||
.header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", copilot_auth::COPILOT_PLUGIN_VERSION)
|
||||
.header("copilot-integration-id", copilot_auth::COPILOT_INTEGRATION_ID)
|
||||
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel");
|
||||
} else if is_openai_chat || is_openai_responses {
|
||||
// OpenAI-compatible targets: Bearer auth + SSE headers only
|
||||
request_builder = request_builder
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity");
|
||||
} else {
|
||||
// Anthropic native: full Claude CLI headers
|
||||
let os_name = Self::get_os_name();
|
||||
@@ -424,18 +446,14 @@ impl StreamCheckService {
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
provider: &Provider,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀),
|
||||
// Responses 端点为 `/responses`。
|
||||
//
|
||||
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。
|
||||
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。
|
||||
let urls = if base.ends_with("/v1") {
|
||||
vec![format!("{base}/responses")]
|
||||
} else {
|
||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||
};
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
let urls = Self::resolve_codex_stream_urls(base_url, is_full_url);
|
||||
|
||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||
@@ -692,6 +710,54 @@ impl StreamCheckService {
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_claude_stream_url(
|
||||
base_url: &str,
|
||||
auth_strategy: AuthStrategy,
|
||||
api_format: &str,
|
||||
is_full_url: bool,
|
||||
) -> String {
|
||||
if is_full_url {
|
||||
return base_url.to_string();
|
||||
}
|
||||
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot;
|
||||
|
||||
if is_github_copilot {
|
||||
format!("{base}/chat/completions")
|
||||
} else if api_format == "openai_responses" {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/responses")
|
||||
} else {
|
||||
format!("{base}/v1/responses")
|
||||
}
|
||||
} else if api_format == "openai_chat" {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
}
|
||||
} else if base.ends_with("/v1") {
|
||||
format!("{base}/messages")
|
||||
} else {
|
||||
format!("{base}/v1/messages")
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec<String> {
|
||||
if is_full_url {
|
||||
return vec![base_url.to_string()];
|
||||
}
|
||||
|
||||
let base = base_url.trim_end_matches('/');
|
||||
|
||||
if base.ends_with("/v1") {
|
||||
vec![format!("{base}/responses")]
|
||||
} else {
|
||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -794,4 +860,95 @@ mod tests {
|
||||
assert_eq!(claude_auth, AuthStrategy::ClaudeAuth);
|
||||
assert_eq!(bearer, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_full_url_mode() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://relay.example/v1/chat/completions",
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_github_copilot() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://api.githubcopilot.com",
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"anthropic",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_openai_chat() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://example.com/v1",
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_openai_responses() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://example.com/v1",
|
||||
AuthStrategy::Bearer,
|
||||
"openai_responses",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_anthropic() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://api.anthropic.com",
|
||||
AuthStrategy::Anthropic,
|
||||
"anthropic",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_full_url_mode() {
|
||||
let urls = StreamCheckService::resolve_codex_stream_urls(
|
||||
"https://relay.example/custom/responses",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(urls, vec!["https://relay.example/custom/responses"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_v1_base() {
|
||||
let urls =
|
||||
StreamCheckService::resolve_codex_stream_urls("https://api.openai.com/v1", false);
|
||||
|
||||
assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_origin_base() {
|
||||
let urls = StreamCheckService::resolve_codex_stream_urls("https://api.openai.com", false);
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"https://api.openai.com/responses",
|
||||
"https://api.openai.com/v1/responses",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
}
|
||||
|
||||
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
|
||||
// OpenCode SQLite sessions use a "sqlite:" prefixed source_path
|
||||
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
|
||||
return opencode::load_messages_sqlite(source_path);
|
||||
}
|
||||
|
||||
let path = Path::new(source_path);
|
||||
match provider_id {
|
||||
"codex" => codex::load_messages(path),
|
||||
@@ -85,6 +90,11 @@ pub fn delete_session(
|
||||
session_id: &str,
|
||||
source_path: &str,
|
||||
) -> Result<bool, String> {
|
||||
// OpenCode SQLite sessions bypass the file-based deletion path
|
||||
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
|
||||
return opencode::delete_session_sqlite(session_id, source_path);
|
||||
}
|
||||
|
||||
let root = provider_root(provider_id)?;
|
||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||
}
|
||||
|
||||
@@ -52,11 +52,25 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let role = message
|
||||
let mut role = message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Claude wraps tool_result inside user messages; reclassify as "tool" role
|
||||
if role == "user" {
|
||||
if let Some(Value::Array(items)) = message.get("content") {
|
||||
let all_tool_results = !items.is_empty()
|
||||
&& items.iter().all(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("tool_result")
|
||||
});
|
||||
if all_tool_results {
|
||||
role = "tool".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
@@ -268,4 +282,58 @@ mod tests {
|
||||
assert!(!path.exists());
|
||||
assert!(!sidecar.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_tool_use_shows_as_assistant() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Write\",\"input\":{\"file_path\":\"a.txt\"}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"File written\"}]},\"timestamp\":\"2026-03-06T10:00:01Z\"}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let msgs = load_messages(&path).expect("load");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].role, "assistant");
|
||||
assert!(msgs[0].content.contains("[Tool: Write]"));
|
||||
assert_eq!(msgs[1].role, "tool");
|
||||
assert_eq!(msgs[1].content, "File written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_mixed_text_and_tool_use() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Let me help.\"},{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Read\",\"input\":{}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let msgs = load_messages(&path).expect("load");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].role, "assistant");
|
||||
assert!(msgs[0].content.contains("Let me help."));
|
||||
assert!(msgs[0].content.contains("[Tool: Read]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_mixed_user_tool_result_and_text_stays_user() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"result\"},{\"type\":\"text\",\"text\":\"Please continue\"}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let msgs = load_messages(&path).expect("load");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].role, "user");
|
||||
assert!(msgs[0].content.contains("Please continue"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,16 +59,37 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
let payload_type = payload.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
// Codex uses separate payload types for tool interactions
|
||||
let (role, content) = match payload_type {
|
||||
"message" => {
|
||||
let role = payload
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let content = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
(role, content)
|
||||
}
|
||||
"function_call" => {
|
||||
let name = payload
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
("assistant".to_string(), format!("[Tool: {name}]"))
|
||||
}
|
||||
"function_call_output" => {
|
||||
let output = payload
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
("tool".to_string(), output)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let role = payload
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let content = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -239,4 +260,36 @@ mod tests {
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_includes_function_call_and_output() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"list files\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call\",\"name\":\"shell\",\"arguments\":\"{\\\"cmd\\\":[\\\"ls\\\"]}\",\"call_id\":\"call_1\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:15Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call_output\",\"call_id\":\"call_1\",\"output\":\"file1.txt\\nfile2.txt\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:16Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Done.\"}]}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let msgs = load_messages(&path).expect("load");
|
||||
assert_eq!(msgs.len(), 4);
|
||||
|
||||
assert_eq!(msgs[0].role, "user");
|
||||
assert_eq!(msgs[0].content, "list files");
|
||||
|
||||
assert_eq!(msgs[1].role, "assistant");
|
||||
assert!(msgs[1].content.contains("[Tool: shell]"));
|
||||
|
||||
assert_eq!(msgs[2].role, "tool");
|
||||
assert!(msgs[2].content.contains("file1.txt"));
|
||||
|
||||
assert_eq!(msgs[3].role, "assistant");
|
||||
assert_eq!(msgs[3].content, "Done.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,21 +60,47 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
|
||||
let mut result = Vec::new();
|
||||
for msg in messages {
|
||||
let content = match msg.get("content").and_then(Value::as_str) {
|
||||
Some(c) if !c.trim().is_empty() => c.to_string(),
|
||||
_ => continue,
|
||||
let role = match msg.get("type").and_then(Value::as_str) {
|
||||
Some("gemini") => "assistant",
|
||||
Some("user") => "user",
|
||||
Some("info") | Some("error") => continue,
|
||||
Some(_) | None => continue,
|
||||
};
|
||||
|
||||
let role = match msg.get("type").and_then(Value::as_str) {
|
||||
Some("gemini") => "assistant".to_string(),
|
||||
Some("user") => "user".to_string(),
|
||||
Some(other) => other.to_string(),
|
||||
None => continue,
|
||||
// Gemini content may be a plain string or an array of {text: ...} objects
|
||||
let mut content = match msg.get("content") {
|
||||
Some(Value::String(s)) => s.to_string(),
|
||||
Some(Value::Array(items)) => items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Append tool call names from the optional toolCalls array
|
||||
if let Some(Value::Array(calls)) = msg.get("toolCalls") {
|
||||
for call in calls {
|
||||
if let Some(name) = call.get("name").and_then(Value::as_str) {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(&format!("[Tool: {name}]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
result.push(SessionMessage { role, content, ts });
|
||||
result.push(SessionMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
ts,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
@@ -172,4 +198,55 @@ mod tests {
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_handles_array_content() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"sessionId": "test",
|
||||
"messages": [
|
||||
{"id":"1","timestamp":"2026-03-06T10:00:00Z","type":"user","content":[{"text":"hello"}]},
|
||||
{"id":"2","timestamp":"2026-03-06T10:00:01Z","type":"gemini","content":"world"},
|
||||
{"id":"3","timestamp":"2026-03-06T10:00:02Z","type":"info","content":"system info"},
|
||||
{"id":"4","timestamp":"2026-03-06T10:00:03Z","type":"error","content":"MCP ERROR"}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let msgs = load_messages(&path).expect("load");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].role, "user");
|
||||
assert_eq!(msgs[0].content, "hello");
|
||||
assert_eq!(msgs[1].role, "assistant");
|
||||
assert_eq!(msgs[1].content, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_includes_tool_calls() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"sessionId": "test",
|
||||
"messages": [
|
||||
{"id":"1","timestamp":"2026-03-10T08:24:50Z","type":"gemini","content":"","toolCalls":[{"id":"call_1","name":"web_search","args":{"query":"test"}}]},
|
||||
{"id":"2","timestamp":"2026-03-10T08:25:00Z","type":"gemini","content":"Here are the results.","toolCalls":[{"id":"call_2","name":"web_fetch","args":{"url":"http://example.com"}}]}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let msgs = load_messages(&path).expect("load");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].role, "assistant");
|
||||
assert!(msgs[0].content.contains("[Tool: web_search]"));
|
||||
assert_eq!(msgs[1].role, "assistant");
|
||||
assert!(msgs[1].content.contains("Here are the results."));
|
||||
assert!(msgs[1].content.contains("[Tool: web_fetch]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
@@ -8,22 +9,59 @@ use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "opencode";
|
||||
|
||||
/// Return the OpenCode data directory.
|
||||
/// Return the OpenCode base directory (`$XDG_DATA_HOME/opencode`).
|
||||
///
|
||||
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
||||
/// `~/.local/share/opencode/storage/`.
|
||||
pub(crate) fn get_opencode_data_dir() -> PathBuf {
|
||||
/// `~/.local/share/opencode/`.
|
||||
pub(crate) fn get_opencode_base_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("opencode").join("storage");
|
||||
return PathBuf::from(xdg).join("opencode");
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".local/share/opencode/storage"))
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
|
||||
.map(|h| h.join(".local/share/opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share/opencode"))
|
||||
}
|
||||
|
||||
/// Return the OpenCode JSON storage directory (legacy flat-file layout).
|
||||
pub(crate) fn get_opencode_data_dir() -> PathBuf {
|
||||
get_opencode_base_dir().join("storage")
|
||||
}
|
||||
|
||||
fn get_opencode_db_path() -> PathBuf {
|
||||
get_opencode_base_dir().join("opencode.db")
|
||||
}
|
||||
|
||||
/// Scan sessions from both the legacy JSON files and the newer SQLite database,
|
||||
/// merging results with SQLite taking precedence on ID conflicts.
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let json_sessions = scan_sessions_json();
|
||||
let sqlite_sessions = scan_sessions_sqlite();
|
||||
|
||||
if sqlite_sessions.is_empty() {
|
||||
return json_sessions;
|
||||
}
|
||||
if json_sessions.is_empty() {
|
||||
return sqlite_sessions;
|
||||
}
|
||||
|
||||
// Deduplicate: keep SQLite version when the same session_id exists in both
|
||||
let sqlite_ids: std::collections::HashSet<String> = sqlite_sessions
|
||||
.iter()
|
||||
.map(|s| s.session_id.clone())
|
||||
.collect();
|
||||
|
||||
let mut merged = sqlite_sessions;
|
||||
for s in json_sessions {
|
||||
if !sqlite_ids.contains(&s.session_id) {
|
||||
merged.push(s);
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn scan_sessions_json() -> Vec<SessionMeta> {
|
||||
let storage = get_opencode_data_dir();
|
||||
let session_dir = storage.join("session");
|
||||
if !session_dir.exists() {
|
||||
@@ -42,6 +80,81 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Parse a SQLite source reference in the format `sqlite:<db_path>:<session_id>`.
|
||||
///
|
||||
/// Uses `rfind(":ses_")` to split the path from the session ID because the
|
||||
/// db path itself may contain colons (e.g. `C:\Users\...` on Windows).
|
||||
/// This relies on the OpenCode convention that session IDs start with `ses_`.
|
||||
fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> {
|
||||
let rest = source.strip_prefix("sqlite:")?;
|
||||
let sep = rest.rfind(":ses_")?;
|
||||
let db_path = PathBuf::from(&rest[..sep]);
|
||||
let session_id = rest[sep + 1..].to_string();
|
||||
Some((db_path, session_id))
|
||||
}
|
||||
|
||||
fn scan_sessions_sqlite() -> Vec<SessionMeta> {
|
||||
let db_path = get_opencode_db_path();
|
||||
if !db_path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let conn = match Connection::open_with_flags(
|
||||
&db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let db_display = db_path.display().to_string();
|
||||
|
||||
let iter = match stmt.query_map([], |row| {
|
||||
let session_id: String = row.get(0)?;
|
||||
let title: String = row.get(1)?;
|
||||
let directory: String = row.get(2)?;
|
||||
let created: i64 = row.get(3)?;
|
||||
let updated: i64 = row.get(4)?;
|
||||
Ok((session_id, title, directory, created, updated))
|
||||
}) {
|
||||
Ok(rows) => rows,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for row in iter.flatten() {
|
||||
let (session_id, title, directory, created, updated) = row;
|
||||
let display_title = if title.is_empty() {
|
||||
path_basename(&directory)
|
||||
} else {
|
||||
Some(title)
|
||||
};
|
||||
sessions.push(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title: display_title.clone(),
|
||||
summary: display_title,
|
||||
project_dir: if directory.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(directory)
|
||||
},
|
||||
created_at: Some(created),
|
||||
last_active_at: Some(updated),
|
||||
source_path: Some(format!("sqlite:{}:{}", db_display, session_id)),
|
||||
resume_command: Some(format!("opencode session resume {session_id}")),
|
||||
});
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
// `path` is the message directory: storage/message/{sessionID}/
|
||||
if !path.is_dir() {
|
||||
@@ -111,6 +224,95 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Load messages from the OpenCode SQLite database for a given source reference.
|
||||
/// Joins the `message` and `part` tables in memory to reconstruct full messages.
|
||||
pub fn load_messages_sqlite(source: &str) -> Result<Vec<SessionMessage>, String> {
|
||||
let (db_path, session_id) = parse_sqlite_source(source)
|
||||
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
|
||||
|
||||
let conn = Connection::open_with_flags(
|
||||
&db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| format!("Failed to open OpenCode database: {e}"))?;
|
||||
|
||||
let mut msg_stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, time_created, data FROM message WHERE session_id = ?1 ORDER BY time_created ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare message query: {e}"))?;
|
||||
|
||||
let msg_rows = msg_stmt
|
||||
.query_map([session_id.as_str()], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let ts: i64 = row.get(1)?;
|
||||
let data: String = row.get(2)?;
|
||||
Ok((id, ts, data))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query messages: {e}"))?;
|
||||
|
||||
let mut part_stmt = conn
|
||||
.prepare(
|
||||
"SELECT message_id, data FROM part WHERE session_id = ?1 ORDER BY time_created ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare part query: {e}"))?;
|
||||
|
||||
let part_rows = part_stmt
|
||||
.query_map([session_id.as_str()], |row| {
|
||||
let message_id: String = row.get(0)?;
|
||||
let data: String = row.get(1)?;
|
||||
Ok((message_id, data))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query parts: {e}"))?;
|
||||
|
||||
let mut parts_map: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for part in part_rows.flatten() {
|
||||
let (message_id, data) = part;
|
||||
parts_map.entry(message_id).or_default().push(data);
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for row in msg_rows.flatten() {
|
||||
let (msg_id, ts, data) = row;
|
||||
let msg_value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let role = msg_value
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let mut texts = Vec::new();
|
||||
if let Some(parts) = parts_map.get(&msg_id) {
|
||||
for part_data in parts {
|
||||
let part_value: Value = match serde_json::from_str(part_data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(text) = extract_part_text(&part_value) {
|
||||
texts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = texts.join("\n");
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push(SessionMessage {
|
||||
role,
|
||||
content,
|
||||
ts: Some(ts),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {
|
||||
return Err(format!(
|
||||
@@ -176,6 +378,48 @@ pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<b
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Delete a session from the OpenCode SQLite database.
|
||||
pub fn delete_session_sqlite(session_id: &str, source: &str) -> Result<bool, String> {
|
||||
let (db_path, ref_session_id) = parse_sqlite_source(source)
|
||||
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
|
||||
let db_path = db_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to canonicalize SQLite database path: {e}"))?;
|
||||
let expected_db_path = get_opencode_db_path()
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to canonicalize expected OpenCode database path: {e}"))?;
|
||||
|
||||
if ref_session_id != session_id {
|
||||
return Err(format!(
|
||||
"OpenCode SQLite session ID mismatch: expected {session_id}, found {ref_session_id}"
|
||||
));
|
||||
}
|
||||
if db_path != expected_db_path {
|
||||
return Err("SQLite path does not match expected OpenCode database".to_string());
|
||||
}
|
||||
|
||||
let conn =
|
||||
Connection::open(&db_path).map_err(|e| format!("Failed to open OpenCode database: {e}"))?;
|
||||
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| format!("Failed to begin transaction: {e}"))?;
|
||||
|
||||
tx.execute("DELETE FROM part WHERE session_id = ?1", [session_id])
|
||||
.map_err(|e| format!("Failed to delete OpenCode parts: {e}"))?;
|
||||
tx.execute("DELETE FROM message WHERE session_id = ?1", [session_id])
|
||||
.map_err(|e| format!("Failed to delete OpenCode messages: {e}"))?;
|
||||
|
||||
let deleted = tx
|
||||
.execute("DELETE FROM session WHERE id = ?1", [session_id])
|
||||
.map_err(|e| format!("Failed to delete OpenCode session: {e}"))?;
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| format!("Failed to commit session deletion: {e}"))?;
|
||||
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
@@ -286,6 +530,24 @@ fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Collect text content from all parts in a part directory.
|
||||
fn extract_part_text(part_value: &Value) -> Option<String> {
|
||||
match part_value.get("type").and_then(Value::as_str) {
|
||||
Some("text") => part_value
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.map(|t| t.to_string()),
|
||||
Some("tool") => {
|
||||
let tool = part_value
|
||||
.get("tool")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
Some(format!("[Tool: {tool}]"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_parts_text(part_dir: &Path) -> String {
|
||||
if !part_dir.is_dir() {
|
||||
return String::new();
|
||||
@@ -305,15 +567,8 @@ fn collect_parts_text(part_dir: &Path) -> String {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Only include text-type parts
|
||||
if value.get("type").and_then(Value::as_str) != Some("text") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
texts.push(text.to_string());
|
||||
}
|
||||
if let Some(text) = extract_part_text(&value) {
|
||||
texts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,8 +625,47 @@ fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opencode_env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn create_sqlite_schema(conn: &Connection) {
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(message_id) REFERENCES message(id) ON DELETE CASCADE
|
||||
);
|
||||
",
|
||||
)
|
||||
.expect("create sqlite schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_session_diff_messages_and_parts() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
@@ -432,4 +726,272 @@ mod tests {
|
||||
.join(format!("{project_id}.json"))
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_includes_tool_parts() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let storage = temp.path();
|
||||
let session_id = "ses_test";
|
||||
let msg_id = "msg_1";
|
||||
|
||||
let msg_dir = storage.join("message").join(session_id);
|
||||
let part_dir = storage.join("part").join(msg_id);
|
||||
std::fs::create_dir_all(&msg_dir).expect("create msg dir");
|
||||
std::fs::create_dir_all(&part_dir).expect("create part dir");
|
||||
|
||||
std::fs::write(
|
||||
msg_dir.join(format!("{msg_id}.json")),
|
||||
r#"{"id":"msg_1","role":"assistant","time":{"created":"2026-03-06T10:00:00Z"}}"#,
|
||||
)
|
||||
.expect("write msg");
|
||||
|
||||
std::fs::write(
|
||||
part_dir.join("prt_1.json"),
|
||||
r#"{"id":"prt_1","type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"ls"},"output":"file.txt"}}"#,
|
||||
)
|
||||
.expect("write tool part");
|
||||
|
||||
std::fs::write(
|
||||
part_dir.join("prt_2.json"),
|
||||
r#"{"id":"prt_2","type":"text","text":"Here are the files."}"#,
|
||||
)
|
||||
.expect("write text part");
|
||||
|
||||
let msgs = load_messages(&msg_dir).expect("load");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].role, "assistant");
|
||||
assert!(msgs[0].content.contains("[Tool: bash]"));
|
||||
assert!(msgs[0].content.contains("Here are the files."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sqlite_source_accepts_valid_references() {
|
||||
let parsed = parse_sqlite_source("sqlite:/tmp/opencode.db:ses_123").expect("valid source");
|
||||
|
||||
assert_eq!(parsed.0, PathBuf::from("/tmp/opencode.db"));
|
||||
assert_eq!(parsed.1, "ses_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sqlite_source_rejects_invalid_references() {
|
||||
assert!(parse_sqlite_source("/tmp/opencode.db:ses_123").is_none());
|
||||
assert!(parse_sqlite_source("sqlite:/tmp/opencode.db:msg_123").is_none());
|
||||
assert!(parse_sqlite_source("sqlite:/tmp/opencode.db").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)] // set_var/remove_var deprecated since Rust 1.81; safe here under mutex
|
||||
fn scan_sessions_sqlite_reads_temp_database() {
|
||||
let _guard = opencode_env_lock().lock().expect("lock");
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let original_xdg = std::env::var_os("XDG_DATA_HOME");
|
||||
std::env::set_var("XDG_DATA_HOME", temp.path());
|
||||
|
||||
let base_dir = temp.path().join("opencode");
|
||||
std::fs::create_dir_all(&base_dir).expect("create base dir");
|
||||
let db_path = base_dir.join("opencode.db");
|
||||
let conn = Connection::open(&db_path).expect("open sqlite db");
|
||||
create_sqlite_schema(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
("ses_1", "", "/tmp/project-a", 1_771_061_953_033_i64, 1_771_061_954_033_i64),
|
||||
)
|
||||
.expect("insert session 1");
|
||||
conn.execute(
|
||||
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
("ses_2", "Named Session", "/tmp/project-b", 1_771_061_950_000_i64, 1_771_061_955_000_i64),
|
||||
)
|
||||
.expect("insert session 2");
|
||||
drop(conn);
|
||||
|
||||
let sessions = scan_sessions_sqlite();
|
||||
|
||||
#[allow(deprecated)]
|
||||
if let Some(value) = original_xdg {
|
||||
std::env::set_var("XDG_DATA_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
}
|
||||
|
||||
assert_eq!(sessions.len(), 2);
|
||||
assert_eq!(sessions[0].session_id, "ses_2");
|
||||
assert_eq!(sessions[0].title.as_deref(), Some("Named Session"));
|
||||
assert_eq!(sessions[1].session_id, "ses_1");
|
||||
assert_eq!(sessions[1].title.as_deref(), Some("project-a"));
|
||||
assert_eq!(sessions[1].project_dir.as_deref(), Some("/tmp/project-a"));
|
||||
let expected_source = format!("sqlite:{}:ses_1", db_path.display());
|
||||
assert_eq!(
|
||||
sessions[1].source_path.as_deref(),
|
||||
Some(expected_source.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_sqlite_reads_messages_and_parts() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let db_path = temp.path().join("opencode.db");
|
||||
let conn = Connection::open(&db_path).expect("open sqlite db");
|
||||
create_sqlite_schema(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
("ses_1", "Session", "/tmp/project-a", 1000_i64, 3000_i64),
|
||||
)
|
||||
.expect("insert session");
|
||||
conn.execute(
|
||||
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
("msg_1", "ses_1", 1000_i64, r#"{"role":"user"}"#),
|
||||
)
|
||||
.expect("insert message 1");
|
||||
conn.execute(
|
||||
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
("msg_2", "ses_1", 2000_i64, r#"{"role":"assistant"}"#),
|
||||
)
|
||||
.expect("insert message 2");
|
||||
conn.execute(
|
||||
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
("prt_1", "ses_1", "msg_1", 1000_i64, r#"{"type":"text","text":"Hello"}"#),
|
||||
)
|
||||
.expect("insert part 1");
|
||||
conn.execute(
|
||||
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
(
|
||||
"prt_2",
|
||||
"ses_1",
|
||||
"msg_2",
|
||||
2000_i64,
|
||||
r#"{"type":"tool","tool":"bash"}"#,
|
||||
),
|
||||
)
|
||||
.expect("insert part 2");
|
||||
conn.execute(
|
||||
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
(
|
||||
"prt_3",
|
||||
"ses_1",
|
||||
"msg_2",
|
||||
2001_i64,
|
||||
r#"{"type":"text","text":"Done"}"#,
|
||||
),
|
||||
)
|
||||
.expect("insert part 3");
|
||||
drop(conn);
|
||||
|
||||
let source = format!("sqlite:{}:ses_1", db_path.display());
|
||||
let messages = load_messages_sqlite(&source).expect("load sqlite messages");
|
||||
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert_eq!(messages[0].content, "Hello");
|
||||
assert_eq!(messages[0].ts, Some(1000));
|
||||
assert_eq!(messages[1].role, "assistant");
|
||||
assert_eq!(messages[1].content, "[Tool: bash]\nDone");
|
||||
assert_eq!(messages[1].ts, Some(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_sqlite_removes_session() {
|
||||
let _guard = opencode_env_lock().lock().expect("lock");
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let original_xdg = std::env::var_os("XDG_DATA_HOME");
|
||||
#[allow(deprecated)]
|
||||
std::env::set_var("XDG_DATA_HOME", temp.path());
|
||||
|
||||
let base_dir = temp.path().join("opencode");
|
||||
std::fs::create_dir_all(&base_dir).expect("create base dir");
|
||||
let db_path = base_dir.join("opencode.db");
|
||||
let conn = Connection::open(&db_path).expect("open sqlite db");
|
||||
create_sqlite_schema(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
("ses_1", "Session", "/tmp/project-a", 1000_i64, 3000_i64),
|
||||
)
|
||||
.expect("insert session");
|
||||
conn.execute(
|
||||
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
("msg_1", "ses_1", 1000_i64, r#"{"role":"user"}"#),
|
||||
)
|
||||
.expect("insert message");
|
||||
conn.execute(
|
||||
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
("prt_1", "ses_1", "msg_1", 1000_i64, r#"{"type":"text","text":"Hello"}"#),
|
||||
)
|
||||
.expect("insert part");
|
||||
drop(conn);
|
||||
|
||||
let source = format!("sqlite:{}:ses_1", db_path.display());
|
||||
let deleted = delete_session_sqlite("ses_1", &source).expect("delete sqlite session");
|
||||
assert!(deleted);
|
||||
|
||||
let conn = Connection::open(&db_path).expect("re-open sqlite db");
|
||||
let remaining_sessions: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM session WHERE id = 'ses_1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count sessions");
|
||||
let remaining_messages: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM message WHERE session_id = 'ses_1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count messages");
|
||||
let remaining_parts: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM part WHERE session_id = 'ses_1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count parts");
|
||||
|
||||
assert_eq!(remaining_sessions, 0);
|
||||
assert_eq!(remaining_messages, 0);
|
||||
assert_eq!(remaining_parts, 0);
|
||||
|
||||
#[allow(deprecated)]
|
||||
if let Some(value) = original_xdg {
|
||||
std::env::set_var("XDG_DATA_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_sqlite_rejects_foreign_db_path() {
|
||||
let _guard = opencode_env_lock().lock().expect("lock");
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let original_xdg = std::env::var_os("XDG_DATA_HOME");
|
||||
#[allow(deprecated)]
|
||||
std::env::set_var("XDG_DATA_HOME", temp.path());
|
||||
|
||||
let expected_base_dir = temp.path().join("opencode");
|
||||
std::fs::create_dir_all(&expected_base_dir).expect("create expected base dir");
|
||||
let expected_db_path = expected_base_dir.join("opencode.db");
|
||||
Connection::open(&expected_db_path).expect("create expected sqlite db");
|
||||
|
||||
let db_path = temp.path().join("foreign.db");
|
||||
let conn = Connection::open(&db_path).expect("open sqlite db");
|
||||
create_sqlite_schema(&conn);
|
||||
conn.execute(
|
||||
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
("ses_1", "Session", "/tmp/project", 1000_i64, 3000_i64),
|
||||
)
|
||||
.expect("insert session");
|
||||
drop(conn);
|
||||
|
||||
let source = format!("sqlite:{}:ses_1", db_path.display());
|
||||
let err = delete_session_sqlite("ses_1", &source).expect_err("should reject foreign db");
|
||||
assert!(err.contains("expected OpenCode database"));
|
||||
|
||||
#[allow(deprecated)]
|
||||
if let Some(value) = original_xdg {
|
||||
std::env::set_var("XDG_DATA_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,15 @@ pub fn read_head_tail_lines(
|
||||
}
|
||||
|
||||
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
|
||||
// Integer: milliseconds (>1e12) or seconds
|
||||
if let Some(n) = value.as_i64() {
|
||||
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
|
||||
}
|
||||
if let Some(n) = value.as_f64() {
|
||||
let n = n as i64;
|
||||
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
|
||||
}
|
||||
// RFC3339 string
|
||||
let raw = value.as_str()?;
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
.ok()
|
||||
@@ -71,6 +80,28 @@ pub fn extract_text(content: &Value) -> String {
|
||||
}
|
||||
|
||||
fn extract_text_from_item(item: &Value) -> Option<String> {
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
// tool_use: show tool name
|
||||
if item_type == "tool_use" {
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
return Some(format!("[Tool: {name}]"));
|
||||
}
|
||||
|
||||
// tool_result: extract nested content
|
||||
if item_type == "tool_result" {
|
||||
if let Some(content) = item.get("content") {
|
||||
let text = extract_text(content);
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
@@ -119,3 +150,25 @@ pub fn path_basename(value: &str) -> Option<String> {
|
||||
.filter(|segment| !segment.is_empty())?;
|
||||
Some(last.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_timestamp_to_ms_supports_integers_and_rfc3339() {
|
||||
assert_eq!(
|
||||
parse_timestamp_to_ms(&json!(1_771_061_953_033_i64)),
|
||||
Some(1_771_061_953_033)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timestamp_to_ms(&json!(1_771_061_953_i64)),
|
||||
Some(1_771_061_953_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timestamp_to_ms(&json!("1970-01-01T00:00:01Z")),
|
||||
Some(1_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,39 +77,10 @@ end tell"#
|
||||
}
|
||||
|
||||
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
// Ghostty usage: open -na Ghostty --args +work-dir=... -e shell -c command
|
||||
|
||||
// Using `open` to launch.
|
||||
let mut args = vec!["-na", "Ghostty", "--args"];
|
||||
|
||||
// Ghostty uses --working-directory for working directory (or +work-dir, but --working-directory is standard in newer versions/compat)
|
||||
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
|
||||
// Documentation says --working-directory is supported in CLI.
|
||||
let work_dir_arg = if let Some(dir) = cwd {
|
||||
format!("--working-directory={dir}")
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
if !work_dir_arg.is_empty() {
|
||||
args.push(&work_dir_arg);
|
||||
}
|
||||
|
||||
// Command execution
|
||||
args.push("-e");
|
||||
|
||||
// We pass the command and its arguments separately.
|
||||
// The previous issue was passing the entire "cmd args" string as a single argument to -e,
|
||||
// which led Ghostty to look for a binary named "cmd args".
|
||||
// Splitting by whitespace allows Ghostty to see ["cmd", "args"].
|
||||
// Note: This assumes simple commands without quoted arguments containing spaces.
|
||||
let full_command = build_shell_command(command, None);
|
||||
for part in full_command.split_whitespace() {
|
||||
args.push(part);
|
||||
}
|
||||
let args = build_ghostty_args(command, cwd);
|
||||
|
||||
let status = Command::new("open")
|
||||
.args(&args)
|
||||
.args(args.iter().map(String::as_str))
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
|
||||
|
||||
@@ -120,6 +91,40 @@ fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ghostty_args(command: &str, cwd: Option<&str>) -> Vec<String> {
|
||||
let input = ghostty_raw_input(command);
|
||||
|
||||
let mut args = vec![
|
||||
"-na".to_string(),
|
||||
"Ghostty".to_string(),
|
||||
"--args".to_string(),
|
||||
"--quit-after-last-window-closed=true".to_string(),
|
||||
];
|
||||
|
||||
if let Some(dir) = cwd {
|
||||
if !dir.trim().is_empty() {
|
||||
args.push(format!("--working-directory={dir}"));
|
||||
}
|
||||
}
|
||||
|
||||
args.push(format!("--input={input}"));
|
||||
args
|
||||
}
|
||||
|
||||
fn ghostty_raw_input(command: &str) -> String {
|
||||
let mut escaped = String::from("raw:");
|
||||
for ch in command.chars() {
|
||||
match ch {
|
||||
'\\' => escaped.push_str("\\\\"),
|
||||
'\n' => escaped.push_str("\\n"),
|
||||
'\r' => escaped.push_str("\\r"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped.push_str("\\n");
|
||||
escaped
|
||||
}
|
||||
|
||||
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
||||
let full_command = build_shell_command(command, cwd);
|
||||
|
||||
@@ -255,3 +260,49 @@ fn shell_escape(value: &str) -> String {
|
||||
fn escape_osascript(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ghostty_uses_shell_mode_for_resume_commands() {
|
||||
let args = build_ghostty_args("claude --resume abc-123", Some("/tmp/project dir"));
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-na",
|
||||
"Ghostty",
|
||||
"--args",
|
||||
"--quit-after-last-window-closed=true",
|
||||
"--working-directory=/tmp/project dir",
|
||||
"--input=raw:claude --resume abc-123\\n",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghostty_keeps_command_without_cwd_prefix_when_not_provided() {
|
||||
let args = build_ghostty_args("claude --resume abc-123", None);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-na",
|
||||
"Ghostty",
|
||||
"--args",
|
||||
"--quit-after-last-window-closed=true",
|
||||
"--input=raw:claude --resume abc-123\\n",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghostty_escapes_newlines_and_backslashes_in_input() {
|
||||
assert_eq!(
|
||||
ghostty_raw_input("echo foo\\\\bar\npwd"),
|
||||
"raw:echo foo\\\\\\\\bar\\npwd\\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.12.2",
|
||||
"version": "3.12.3",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:",
|
||||
"csp": "default-src 'self'; img-src 'self' data: https: http:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": []
|
||||
@@ -50,7 +50,7 @@
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
"minimumSystemVersion": "12.0"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -10,7 +10,9 @@ use cc_switch_lib::{
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||||
use support::{
|
||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn import_default_config_claude_persists_provider() {
|
||||
@@ -560,3 +562,96 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||||
"~/.claude.json should still not exist after skipped sync"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let mcp_path = get_claude_mcp_path();
|
||||
fs::write(
|
||||
&mcp_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"mcpServers": {
|
||||
"managed-disabled": {
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
},
|
||||
"external-only": {
|
||||
"type": "stdio",
|
||||
"command": "external"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("serialize claude mcp"),
|
||||
)
|
||||
.expect("seed claude mcp");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&McpServer {
|
||||
id: "managed-disabled".to_string(),
|
||||
name: "Managed Disabled".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
})
|
||||
.expect("save disabled server");
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&McpServer {
|
||||
id: "managed-enabled".to_string(),
|
||||
name: "Managed Enabled".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "managed"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
})
|
||||
.expect("save enabled server");
|
||||
|
||||
McpService::sync_all_enabled(&state).expect("reconcile mcp");
|
||||
|
||||
let text = fs::read_to_string(&mcp_path).expect("read claude mcp");
|
||||
let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude mcp");
|
||||
let servers = value
|
||||
.get("mcpServers")
|
||||
.and_then(|entry| entry.as_object())
|
||||
.expect("mcpServers object");
|
||||
|
||||
assert!(
|
||||
!servers.contains_key("managed-disabled"),
|
||||
"DB-known disabled server should be removed from live config"
|
||||
);
|
||||
assert!(
|
||||
servers.contains_key("managed-enabled"),
|
||||
"DB-known enabled server should be present in live config"
|
||||
);
|
||||
assert!(
|
||||
servers.contains_key("external-only"),
|
||||
"live entries unknown to DB should be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
use std::fs;
|
||||
|
||||
use cc_switch_lib::{
|
||||
migrate_skills_to_ssot, AppType, ImportSkillSelection, InstalledSkill, SkillApps, SkillService,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||
|
||||
fn write_skill(dir: &std::path::Path, name: &str) {
|
||||
fs::create_dir_all(dir).expect("create skill dir");
|
||||
fs::write(
|
||||
dir.join("SKILL.md"),
|
||||
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
|
||||
)
|
||||
.expect("write SKILL.md");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
|
||||
std::os::unix::fs::symlink(src, dest).expect("create symlink");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
|
||||
std::os::windows::fs::symlink_dir(src, dest).expect("create symlink");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_from_apps_respects_explicit_app_selection() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
write_skill(
|
||||
&home.join(".claude").join("skills").join("shared-skill"),
|
||||
"Shared",
|
||||
);
|
||||
write_skill(
|
||||
&home
|
||||
.join(".config")
|
||||
.join("opencode")
|
||||
.join("skills")
|
||||
.join("shared-skill"),
|
||||
"Shared",
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
let imported = SkillService::import_from_apps(
|
||||
&state.db,
|
||||
vec![ImportSkillSelection {
|
||||
directory: "shared-skill".to_string(),
|
||||
apps: SkillApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: true,
|
||||
},
|
||||
}],
|
||||
)
|
||||
.expect("import skills");
|
||||
|
||||
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
|
||||
let skill = imported.first().expect("imported skill");
|
||||
assert!(
|
||||
skill.apps.opencode,
|
||||
"explicitly selected OpenCode app should remain enabled"
|
||||
);
|
||||
assert!(
|
||||
!skill.apps.claude && !skill.apps.codex && !skill.apps.gemini,
|
||||
"import should no longer infer apps from every matching source path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let ssot_dir = home.join(".cc-switch").join("skills");
|
||||
let disabled_skill = ssot_dir.join("disabled-skill");
|
||||
let orphan_skill = ssot_dir.join("orphan-skill");
|
||||
write_skill(&disabled_skill, "Disabled");
|
||||
write_skill(&orphan_skill, "Orphan");
|
||||
|
||||
let opencode_skills_dir = home.join(".config").join("opencode").join("skills");
|
||||
fs::create_dir_all(&opencode_skills_dir).expect("create opencode skills dir");
|
||||
symlink_dir(&disabled_skill, &opencode_skills_dir.join("disabled-skill"));
|
||||
symlink_dir(&orphan_skill, &opencode_skills_dir.join("orphan-skill"));
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
state
|
||||
.db
|
||||
.save_skill(&InstalledSkill {
|
||||
id: "local:disabled-skill".to_string(),
|
||||
name: "Disabled".to_string(),
|
||||
description: None,
|
||||
directory: "disabled-skill".to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 0,
|
||||
})
|
||||
.expect("save disabled skill");
|
||||
|
||||
SkillService::sync_to_app(&state.db, &AppType::OpenCode).expect("reconcile skills");
|
||||
|
||||
assert!(
|
||||
!opencode_skills_dir.join("disabled-skill").exists(),
|
||||
"DB-known disabled skill should be removed from OpenCode live dir"
|
||||
);
|
||||
assert!(
|
||||
!opencode_skills_dir.join("orphan-skill").exists(),
|
||||
"orphaned symlink into SSOT should be cleaned up"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_skill_creates_backup_before_removing_ssot() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("backup-skill");
|
||||
write_skill(&ssot_skill_dir, "Backup Skill");
|
||||
fs::write(ssot_skill_dir.join("prompt.md"), "backup me").expect("write prompt.md");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
state
|
||||
.db
|
||||
.save_skill(&InstalledSkill {
|
||||
id: "local:backup-skill".to_string(),
|
||||
name: "Backup Skill".to_string(),
|
||||
description: Some("Back me up before uninstall".to_string()),
|
||||
directory: "backup-skill".to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 123,
|
||||
})
|
||||
.expect("save skill");
|
||||
|
||||
let result = SkillService::uninstall(&state.db, "local:backup-skill").expect("uninstall skill");
|
||||
let backup_path = result.backup_path.expect("backup path should be returned");
|
||||
let backup_dir = std::path::PathBuf::from(&backup_path);
|
||||
|
||||
assert!(backup_dir.exists(), "backup directory should exist");
|
||||
assert!(
|
||||
backup_dir.join("skill").join("SKILL.md").exists(),
|
||||
"backup should include SKILL.md"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(backup_dir.join("skill").join("prompt.md"))
|
||||
.expect("read backed up prompt"),
|
||||
"backup me"
|
||||
);
|
||||
|
||||
let metadata: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(backup_dir.join("meta.json")).expect("read backup metadata"),
|
||||
)
|
||||
.expect("parse backup metadata");
|
||||
assert_eq!(metadata["skill"]["directory"], "backup-skill");
|
||||
assert_eq!(metadata["skill"]["name"], "Backup Skill");
|
||||
|
||||
assert!(
|
||||
!ssot_skill_dir.exists(),
|
||||
"SSOT skill directory should be removed after uninstall"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.get_installed_skill("local:backup-skill")
|
||||
.expect("query skill")
|
||||
.is_none(),
|
||||
"database row should be deleted after uninstall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("restore-skill");
|
||||
write_skill(&ssot_skill_dir, "Restore Skill");
|
||||
fs::write(ssot_skill_dir.join("prompt.md"), "restore me").expect("write prompt.md");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
state
|
||||
.db
|
||||
.save_skill(&InstalledSkill {
|
||||
id: "local:restore-skill".to_string(),
|
||||
name: "Restore Skill".to_string(),
|
||||
description: Some("Bring the files back".to_string()),
|
||||
directory: "restore-skill".to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 456,
|
||||
})
|
||||
.expect("save skill");
|
||||
|
||||
let uninstall =
|
||||
SkillService::uninstall(&state.db, "local:restore-skill").expect("uninstall skill");
|
||||
let backup_id = std::path::Path::new(
|
||||
&uninstall
|
||||
.backup_path
|
||||
.expect("backup path should be returned on uninstall"),
|
||||
)
|
||||
.file_name()
|
||||
.expect("backup dir name")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let restored = SkillService::restore_from_backup(&state.db, &backup_id, &AppType::Claude)
|
||||
.expect("restore from backup");
|
||||
|
||||
assert_eq!(restored.directory, "restore-skill");
|
||||
assert!(restored.apps.claude, "restored skill should enable Claude");
|
||||
assert!(
|
||||
!restored.apps.codex && !restored.apps.gemini && !restored.apps.opencode,
|
||||
"restore should only enable the selected app"
|
||||
);
|
||||
assert!(
|
||||
home.join(".cc-switch")
|
||||
.join("skills")
|
||||
.join("restore-skill")
|
||||
.join("prompt.md")
|
||||
.exists(),
|
||||
"restored skill should exist in SSOT"
|
||||
);
|
||||
assert!(
|
||||
home.join(".claude")
|
||||
.join("skills")
|
||||
.join("restore-skill")
|
||||
.join("prompt.md")
|
||||
.exists(),
|
||||
"restored skill should sync to the selected app"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.get_installed_skill("local:restore-skill")
|
||||
.expect("query restored skill")
|
||||
.is_some(),
|
||||
"restored skill should be written back to the database"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_skill_backup_removes_backup_directory() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let ssot_skill_dir = home
|
||||
.join(".cc-switch")
|
||||
.join("skills")
|
||||
.join("delete-backup-skill");
|
||||
write_skill(&ssot_skill_dir, "Delete Backup Skill");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
state
|
||||
.db
|
||||
.save_skill(&InstalledSkill {
|
||||
id: "local:delete-backup-skill".to_string(),
|
||||
name: "Delete Backup Skill".to_string(),
|
||||
description: Some("Remove my backup".to_string()),
|
||||
directory: "delete-backup-skill".to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
installed_at: 789,
|
||||
})
|
||||
.expect("save skill");
|
||||
|
||||
let uninstall =
|
||||
SkillService::uninstall(&state.db, "local:delete-backup-skill").expect("uninstall skill");
|
||||
let backup_path = uninstall
|
||||
.backup_path
|
||||
.expect("backup path should be returned on uninstall");
|
||||
let backup_id = std::path::Path::new(&backup_path)
|
||||
.file_name()
|
||||
.expect("backup dir name")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
std::path::Path::new(&backup_path).exists(),
|
||||
"backup directory should exist before deletion"
|
||||
);
|
||||
|
||||
SkillService::delete_backup(&backup_id).expect("delete backup");
|
||||
|
||||
assert!(
|
||||
!std::path::Path::new(&backup_path).exists(),
|
||||
"backup directory should be removed"
|
||||
);
|
||||
assert!(
|
||||
SkillService::list_backups()
|
||||
.expect("list backups")
|
||||
.into_iter()
|
||||
.all(|entry| entry.backup_id != backup_id),
|
||||
"deleted backup should no longer appear in backup list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_snapshot_overrides_multi_source_directory_inference() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
write_skill(
|
||||
&home.join(".claude").join("skills").join("demo-skill"),
|
||||
"Demo",
|
||||
);
|
||||
write_skill(
|
||||
&home
|
||||
.join(".config")
|
||||
.join("opencode")
|
||||
.join("skills")
|
||||
.join("demo-skill"),
|
||||
"Demo",
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_setting(
|
||||
"skills_ssot_migration_snapshot",
|
||||
r#"[{"directory":"demo-skill","app_type":"claude"}]"#,
|
||||
)
|
||||
.expect("seed migration snapshot");
|
||||
|
||||
let count = migrate_skills_to_ssot(&state.db).expect("migrate skills to ssot");
|
||||
assert_eq!(count, 1, "expected one migrated skill");
|
||||
|
||||
let skills = state.db.get_all_installed_skills().expect("get skills");
|
||||
let migrated = skills
|
||||
.values()
|
||||
.find(|skill| skill.directory == "demo-skill")
|
||||
.expect("migrated demo-skill");
|
||||
|
||||
assert!(
|
||||
migrated.apps.claude,
|
||||
"legacy snapshot should preserve Claude enablement"
|
||||
);
|
||||
assert!(
|
||||
!migrated.apps.opencode,
|
||||
"migration should no longer infer OpenCode enablement from a duplicate directory alone"
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,14 @@ pub fn ensure_test_home() -> &'static Path {
|
||||
/// 清理测试目录中生成的配置文件与缓存。
|
||||
pub fn reset_test_fs() {
|
||||
let home = ensure_test_home();
|
||||
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
|
||||
for sub in [
|
||||
".claude",
|
||||
".codex",
|
||||
".cc-switch",
|
||||
".gemini",
|
||||
".config",
|
||||
".openclaw",
|
||||
] {
|
||||
let path = home.join(sub);
|
||||
if path.exists() {
|
||||
if let Err(err) = std::fs::remove_dir_all(&path) {
|
||||
|
||||
+15
-7
@@ -255,7 +255,7 @@ function App() {
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp);
|
||||
} = useProviderActions(activeApp, isProxyRunning);
|
||||
|
||||
const disableOmoMutation = useDisableCurrentOmo();
|
||||
const handleDisableOmo = () => {
|
||||
@@ -712,17 +712,14 @@ function App() {
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||
currentApp={activeApp === "openclaw" ? "claude" : activeApp}
|
||||
/>
|
||||
);
|
||||
case "skillsDiscovery":
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
initialApp={
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
? "claude"
|
||||
: activeApp
|
||||
}
|
||||
initialApp={activeApp === "openclaw" ? "claude" : activeApp}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -755,7 +752,7 @@ function App() {
|
||||
return <AgentsDefaultsPanel />;
|
||||
default:
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
@@ -1037,6 +1034,17 @@ function App() {
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openRestoreFromBackup()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
{t("skills.restoreFromBackup.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ConfirmDialogProps {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "destructive" | "info";
|
||||
zIndex?: "base" | "nested" | "alert" | "top";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export function ConfirmDialog({
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = "destructive",
|
||||
zIndex = "alert",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
@@ -46,7 +48,7 @@ export function ConfirmDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm" zIndex="alert">
|
||||
<DialogContent className="max-w-sm" zIndex={zIndex}>
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<IconComponent className={iconClass} />
|
||||
|
||||
+182
-106
@@ -5,7 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Provider, UsageScript, UsageData } from "@/types";
|
||||
import { usageApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
@@ -18,6 +20,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TEMPLATE_TYPES, PROVIDER_TYPES } from "@/config/constants";
|
||||
|
||||
interface UsageScriptModalProps {
|
||||
provider: Provider;
|
||||
@@ -27,18 +30,11 @@ interface UsageScriptModalProps {
|
||||
onSave: (script: UsageScript) => void;
|
||||
}
|
||||
|
||||
// 预设模板键名(用于国际化)
|
||||
const TEMPLATE_KEYS = {
|
||||
CUSTOM: "custom",
|
||||
GENERAL: "general",
|
||||
NEW_API: "newapi",
|
||||
} as const;
|
||||
|
||||
// 生成预设模板的函数(支持国际化)
|
||||
const generatePresetTemplates = (
|
||||
t: (key: string) => string,
|
||||
): Record<string, string> => ({
|
||||
[TEMPLATE_KEYS.CUSTOM]: `({
|
||||
[TEMPLATE_TYPES.CUSTOM]: `({
|
||||
request: {
|
||||
url: "",
|
||||
method: "GET",
|
||||
@@ -52,7 +48,7 @@ const generatePresetTemplates = (
|
||||
}
|
||||
})`,
|
||||
|
||||
[TEMPLATE_KEYS.GENERAL]: `({
|
||||
[TEMPLATE_TYPES.GENERAL]: `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/user/balance",
|
||||
method: "GET",
|
||||
@@ -70,7 +66,7 @@ const generatePresetTemplates = (
|
||||
}
|
||||
})`,
|
||||
|
||||
[TEMPLATE_KEYS.NEW_API]: `({
|
||||
[TEMPLATE_TYPES.NEW_API]: `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/user/self",
|
||||
method: "GET",
|
||||
@@ -96,13 +92,17 @@ const generatePresetTemplates = (
|
||||
};
|
||||
},
|
||||
})`,
|
||||
|
||||
// GitHub Copilot 模板不需要脚本,使用专用 API
|
||||
[TEMPLATE_TYPES.GITHUB_COPILOT]: "",
|
||||
});
|
||||
|
||||
// 模板名称国际化键映射
|
||||
const TEMPLATE_NAME_KEYS: Record<string, string> = {
|
||||
[TEMPLATE_KEYS.CUSTOM]: "usageScript.templateCustom",
|
||||
[TEMPLATE_KEYS.GENERAL]: "usageScript.templateGeneral",
|
||||
[TEMPLATE_KEYS.NEW_API]: "usageScript.templateNewAPI",
|
||||
[TEMPLATE_TYPES.CUSTOM]: "usageScript.templateCustom",
|
||||
[TEMPLATE_TYPES.GENERAL]: "usageScript.templateGeneral",
|
||||
[TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI",
|
||||
[TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot",
|
||||
};
|
||||
|
||||
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
@@ -167,7 +167,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const defaultScript = {
|
||||
enabled: false,
|
||||
language: "javascript" as const,
|
||||
code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL],
|
||||
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
|
||||
timeout: 10,
|
||||
};
|
||||
|
||||
@@ -230,6 +230,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||
() => {
|
||||
const existingScript = provider.meta?.usage_script;
|
||||
// Copilot 供应商默认使用 Copilot 模板
|
||||
if (provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT) {
|
||||
return TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
}
|
||||
// 优先使用保存的 templateType
|
||||
if (existingScript?.templateType) {
|
||||
return existingScript.templateType;
|
||||
@@ -237,14 +241,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
// 向后兼容:根据字段推断模板类型
|
||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||
if (existingScript?.accessToken || existingScript?.userId) {
|
||||
return TEMPLATE_KEYS.NEW_API;
|
||||
return TEMPLATE_TYPES.NEW_API;
|
||||
}
|
||||
// 检测 GENERAL 模板(有 apiKey 或 baseUrl)
|
||||
if (existingScript?.apiKey || existingScript?.baseUrl) {
|
||||
return TEMPLATE_KEYS.GENERAL;
|
||||
return TEMPLATE_TYPES.GENERAL;
|
||||
}
|
||||
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
|
||||
return TEMPLATE_KEYS.GENERAL;
|
||||
return TEMPLATE_TYPES.GENERAL;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -263,7 +267,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
setShowUsageConfirm(false);
|
||||
try {
|
||||
if (settingsData) {
|
||||
await settingsApi.save({ ...settingsData, usageConfirmed: true });
|
||||
const { webdavSync: _, ...rest } = settingsData;
|
||||
await settingsApi.save({ ...rest, usageConfirmed: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -273,13 +278,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
toast.error(t("usageScript.scriptEmpty"));
|
||||
return;
|
||||
}
|
||||
if (script.enabled && !script.code.includes("return")) {
|
||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||
return;
|
||||
// Copilot 模板不需要脚本验证
|
||||
if (selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT) {
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
toast.error(t("usageScript.scriptEmpty"));
|
||||
return;
|
||||
}
|
||||
if (script.enabled && !script.code.includes("return")) {
|
||||
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 保存时记录当前选择的模板类型
|
||||
const scriptWithTemplate = {
|
||||
@@ -288,6 +296,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
| "custom"
|
||||
| "general"
|
||||
| "newapi"
|
||||
| "github_copilot"
|
||||
| undefined,
|
||||
};
|
||||
onSave(scriptWithTemplate);
|
||||
@@ -297,6 +306,38 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
// Copilot 模板使用专用 API
|
||||
if (selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT) {
|
||||
const accountId = resolveManagedAccountId(
|
||||
provider.meta,
|
||||
PROVIDER_TYPES.GITHUB_COPILOT,
|
||||
);
|
||||
const usage = accountId
|
||||
? await copilotGetUsageForAccount(accountId)
|
||||
: await copilotGetUsage();
|
||||
const premium = usage.quota_snapshots.premium_interactions;
|
||||
const used = premium.entitlement - premium.remaining;
|
||||
const summary = `[${usage.copilot_plan}] ${t("usage.remaining")} ${premium.remaining}/${premium.entitlement} (${t("usageScript.resetDate")}: ${usage.quota_reset_date})`;
|
||||
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
||||
duration: 3000,
|
||||
closeButton: true,
|
||||
});
|
||||
// 更新缓存
|
||||
queryClient.setQueryData(["usage", provider.id, appId], {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
planName: usage.copilot_plan,
|
||||
remaining: premium.remaining,
|
||||
total: premium.entitlement,
|
||||
used: used,
|
||||
unit: t("usageScript.premiumRequests"),
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await usageApi.testScript(
|
||||
provider.id,
|
||||
appId,
|
||||
@@ -370,7 +411,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleUsePreset = (presetName: string) => {
|
||||
const preset = PRESET_TEMPLATES[presetName];
|
||||
if (preset) {
|
||||
if (presetName === TEMPLATE_KEYS.CUSTOM) {
|
||||
if (presetName === TEMPLATE_TYPES.CUSTOM) {
|
||||
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||
// 这样可以避免同源检查导致的问题
|
||||
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
|
||||
@@ -383,27 +424,37 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
|
||||
} else if (presetName === TEMPLATE_TYPES.GENERAL) {
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
|
||||
} else if (presetName === TEMPLATE_TYPES.NEW_API) {
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
apiKey: undefined,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_TYPES.GITHUB_COPILOT) {
|
||||
// Copilot 模板不需要脚本和凭证,使用专用 API
|
||||
setScript({
|
||||
...script,
|
||||
code: "",
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
}
|
||||
setSelectedTemplate(presetName);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowCredentialsConfig =
|
||||
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
|
||||
selectedTemplate === TEMPLATE_KEYS.NEW_API;
|
||||
selectedTemplate === TEMPLATE_TYPES.GENERAL ||
|
||||
selectedTemplate === TEMPLATE_TYPES.NEW_API;
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
@@ -474,30 +525,40 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{t("usageScript.presetTemplate")}
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.keys(PRESET_TEMPLATES).map((name) => {
|
||||
const isSelected = selectedTemplate === name;
|
||||
return (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-lg border",
|
||||
isSelected
|
||||
? "shadow-sm"
|
||||
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
onClick={() => handleUsePreset(name)}
|
||||
>
|
||||
{t(TEMPLATE_NAME_KEYS[name])}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{Object.keys(PRESET_TEMPLATES)
|
||||
.filter((name) => {
|
||||
const isCopilotProvider =
|
||||
provider.meta?.providerType === "github_copilot";
|
||||
// Copilot 供应商只显示 copilot 模板,其他供应商不显示 copilot 模板
|
||||
if (isCopilotProvider) {
|
||||
return name === TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
}
|
||||
return name !== TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
})
|
||||
.map((name) => {
|
||||
const isSelected = selectedTemplate === name;
|
||||
return (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-lg border",
|
||||
isSelected
|
||||
? "shadow-sm"
|
||||
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
onClick={() => handleUsePreset(name)}
|
||||
>
|
||||
{t(TEMPLATE_NAME_KEYS[name])}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 自定义模式:变量提示和具体值 */}
|
||||
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
|
||||
{selectedTemplate === TEMPLATE_TYPES.CUSTOM && (
|
||||
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t("usageScript.supportedVariables")}
|
||||
@@ -564,6 +625,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copilot 模式:自动认证提示 */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT && (
|
||||
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("usageScript.copilotAutoAuth")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 凭证配置 */}
|
||||
{shouldShowCredentialsConfig && (
|
||||
<div className="space-y-4">
|
||||
@@ -577,7 +647,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
||||
{selectedTemplate === TEMPLATE_TYPES.GENERAL && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-api-key">
|
||||
@@ -641,7 +711,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
||||
{selectedTemplate === TEMPLATE_TYPES.NEW_API && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-newapi-base-url">
|
||||
@@ -789,34 +859,39 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提取器代码 */}
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.extractorCode")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usageScript.extractorHint")}
|
||||
{/* 提取器代码 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.extractorCode")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usageScript.extractorHint")}
|
||||
</div>
|
||||
</div>
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) => setScript({ ...script, code: value })}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) => setScript({ ...script, code: value })}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 帮助信息 */}
|
||||
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
|
||||
<h4 className="font-medium mb-2">{t("usageScript.scriptHelp")}</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
{/* 帮助信息 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
|
||||
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
|
||||
<h4 className="font-medium mb-2">
|
||||
{t("usageScript.scriptHelp")}
|
||||
</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/usage",
|
||||
method: "POST",
|
||||
@@ -833,38 +908,39 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
}
|
||||
})`}
|
||||
</pre>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
<div className="text-muted-foreground">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ interface AgentsPanelProps {
|
||||
|
||||
export function AgentsPanel({}: AgentsPanelProps) {
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
|
||||
<Bot className="w-10 h-10 text-muted-foreground" />
|
||||
|
||||
@@ -132,7 +132,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<AppCountBar
|
||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||
counts={enabledCounts}
|
||||
|
||||
@@ -96,7 +96,7 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)] px-6">
|
||||
<div className="flex flex-col flex-1 min-h-0 px-6">
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("prompts.count", { count: promptEntries.length })} ·{" "}
|
||||
|
||||
@@ -42,12 +42,13 @@ export function AddProviderDialog({
|
||||
const { t } = useTranslation();
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"app-specific" | "universal"
|
||||
>("app-specific");
|
||||
const [universalFormOpen, setUniversalFormOpen] = useState(false);
|
||||
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
||||
useState<UniversalProviderPreset | null>(null);
|
||||
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||
|
||||
const handleUniversalProviderSave = useCallback(
|
||||
async (provider: UniversalProvider) => {
|
||||
@@ -247,6 +248,7 @@ export function AddProviderDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-form"
|
||||
disabled={isFormSubmitting}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
@@ -282,7 +284,9 @@ export function AddProviderDialog({
|
||||
{showUniversalTab ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "app-specific" | "universal")
|
||||
}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="app-specific">
|
||||
@@ -299,6 +303,7 @@ export function AddProviderDialog({
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmittingChange={setIsFormSubmitting}
|
||||
showButtons={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -314,6 +319,7 @@ export function AddProviderDialog({
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmittingChange={setIsFormSubmitting}
|
||||
showButtons={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function EditProviderDialog({
|
||||
isProxyTakeover = false,
|
||||
}: EditProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||
|
||||
// 默认使用传入的 provider.settingsConfig,若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
|
||||
const [liveSettings, setLiveSettings] = useState<Record<
|
||||
@@ -197,6 +198,7 @@ export function EditProviderDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-form"
|
||||
disabled={isFormSubmitting}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
@@ -210,6 +212,7 @@ export function EditProviderDialog({
|
||||
submitLabel={t("common.save")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSubmittingChange={setIsFormSubmitting}
|
||||
initialData={initialData}
|
||||
showButtons={false}
|
||||
/>
|
||||
|
||||
@@ -194,16 +194,19 @@ export function ProviderCard({
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - OMO/OMO Slim 供应商:使用 isCurrent
|
||||
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
||||
// - OpenClaw:使用默认模型归属的 provider 作为当前项(蓝色边框)
|
||||
// - OpenCode(非 OMO):不存在"当前"概念,返回 false
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isAnyOmo
|
||||
? isCurrent
|
||||
: appId === "opencode" || appId === "openclaw"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
: appId === "openclaw"
|
||||
? Boolean(isDefaultModel)
|
||||
: appId === "opencode"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue =
|
||||
|
||||
@@ -204,7 +204,8 @@ export function ProviderList({
|
||||
setShowStreamCheckConfirm(false);
|
||||
try {
|
||||
if (settings) {
|
||||
await settingsApi.save({ ...settings, streamCheckConfirmed: true });
|
||||
const { webdavSync: _, ...rest } = settings;
|
||||
await settingsApi.save({ ...rest, streamCheckConfirmed: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { toast } from "sonner";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -8,8 +16,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import { CopilotAuthSection } from "./CopilotAuthSection";
|
||||
import {
|
||||
copilotGetModels,
|
||||
copilotGetModelsForAccount,
|
||||
} from "@/lib/api/copilot";
|
||||
import type { CopilotModel } from "@/lib/api/copilot";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
@@ -33,6 +56,15 @@ interface ClaudeFormFieldsProps {
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// GitHub Copilot OAuth
|
||||
isCopilotPreset?: boolean;
|
||||
usesOAuth?: boolean;
|
||||
isCopilotAuthenticated?: boolean;
|
||||
/** 当前选中的 GitHub 账号 ID(多账号支持) */
|
||||
selectedGitHubAccountId?: string | null;
|
||||
/** GitHub 账号选择回调(多账号支持) */
|
||||
onGitHubAccountSelect?: (accountId: string | null) => void;
|
||||
|
||||
// Template Values
|
||||
templateValueEntries: Array<[string, TemplateValueConfig]>;
|
||||
templateValues: Record<string, TemplateValueConfig>;
|
||||
@@ -76,6 +108,10 @@ interface ClaudeFormFieldsProps {
|
||||
// Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)
|
||||
apiKeyField: ClaudeApiKeyField;
|
||||
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||
|
||||
// Full URL mode
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -88,6 +124,11 @@ export function ClaudeFormFields({
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
isCopilotPreset,
|
||||
usesOAuth,
|
||||
isCopilotAuthenticated,
|
||||
selectedGitHubAccountId,
|
||||
onGitHubAccountSelect,
|
||||
templateValueEntries,
|
||||
templateValues,
|
||||
templatePresetName,
|
||||
@@ -112,13 +153,176 @@ export function ClaudeFormFields({
|
||||
onApiFormatChange,
|
||||
apiKeyField,
|
||||
onApiKeyFieldChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasAnyAdvancedValue = !!(
|
||||
claudeModel ||
|
||||
reasoningModel ||
|
||||
defaultHaikuModel ||
|
||||
defaultSonnetModel ||
|
||||
defaultOpusModel ||
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
);
|
||||
const [advancedExpanded, setAdvancedExpanded] =
|
||||
useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
useEffect(() => {
|
||||
if (hasAnyAdvancedValue) {
|
||||
setAdvancedExpanded(true);
|
||||
}
|
||||
}, [hasAnyAdvancedValue]);
|
||||
|
||||
// Copilot 可用模型列表
|
||||
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
|
||||
// 当 Copilot 预设且已认证时,加载可用模型
|
||||
useEffect(() => {
|
||||
// 如果不是 Copilot 预设或未认证,清空模型列表
|
||||
if (!isCopilotPreset || !isCopilotAuthenticated) {
|
||||
setCopilotModels([]);
|
||||
setModelsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
const fetchModels = selectedGitHubAccountId
|
||||
? copilotGetModelsForAccount(selectedGitHubAccountId)
|
||||
: copilotGetModels();
|
||||
|
||||
fetchModels
|
||||
.then((models) => {
|
||||
if (!cancelled) setCopilotModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[Copilot] Failed to fetch models:", err);
|
||||
if (!cancelled) {
|
||||
toast.error(
|
||||
t("copilot.loadModelsFailed", {
|
||||
defaultValue: "加载 Copilot 模型列表失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isCopilotPreset, isCopilotAuthenticated, selectedGitHubAccountId]);
|
||||
|
||||
// 模型输入框:支持手动输入 + 下拉选择
|
||||
const renderModelInput = (
|
||||
id: string,
|
||||
value: string,
|
||||
field: ClaudeFormFieldsProps["onModelChange"] extends (
|
||||
f: infer F,
|
||||
v: string,
|
||||
) => void
|
||||
? F
|
||||
: never,
|
||||
placeholder?: string,
|
||||
) => {
|
||||
if (isCopilotPreset && copilotModels.length > 0) {
|
||||
// 按 vendor 分组
|
||||
const grouped: Record<string, CopilotModel[]> = {};
|
||||
for (const model of copilotModels) {
|
||||
const vendor = model.vendor || "Other";
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(model);
|
||||
}
|
||||
const vendors = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{vendors.map((vendor, vi) => (
|
||||
<div key={vendor}>
|
||||
{vi > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
|
||||
{grouped[vendor].map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onSelect={() => onModelChange(field, model.id)}
|
||||
>
|
||||
{model.id}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopilotPreset && modelsLoading) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon" className="shrink-0" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* API Key 输入框 */}
|
||||
{shouldShowApiKey && (
|
||||
{/* GitHub Copilot OAuth 认证 */}
|
||||
{isCopilotPreset && (
|
||||
<CopilotAuthSection
|
||||
selectedAccountId={selectedGitHubAccountId}
|
||||
onAccountSelect={onGitHubAccountSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API Key 输入框(非 OAuth 预设时显示) */}
|
||||
{shouldShowApiKey && !usesOAuth && (
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
@@ -181,6 +385,9 @@ export function ClaudeFormFields({
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
showFullUrlToggle={true}
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -200,188 +407,185 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API 格式选择(仅非官方、非云服务商显示) */}
|
||||
{shouldShowModelSelector && category !== "cloud_provider" && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||
<SelectTrigger id="apiFormat" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.apiFormatHint", {
|
||||
defaultValue: "选择供应商 API 的输入格式",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证字段选择器 */}
|
||||
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("providerForm.authFieldAuthToken", {
|
||||
defaultValue: "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("providerForm.authFieldApiKey", {
|
||||
defaultValue: "ANTHROPIC_API_KEY",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.authFieldHint", {
|
||||
defaultValue: "选择写入配置的认证环境变量名",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Collapsible
|
||||
open={advancedExpanded}
|
||||
onOpenChange={setAdvancedExpanded}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={null}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
|
||||
>
|
||||
{advancedExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("providerForm.advancedOptionsToggle")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{!advancedExpanded && (
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-1">
|
||||
{t("providerForm.advancedOptionsHint")}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* API 格式选择(仅非云服务商显示) */}
|
||||
{category !== "cloud_provider" && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||
<SelectTrigger id="apiFormat" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_responses">
|
||||
{t("providerForm.apiFormatOpenAIResponses", {
|
||||
defaultValue: "OpenAI Responses API (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.apiFormatHint", {
|
||||
defaultValue: "选择供应商 API 的输入格式",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型选择器 */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 主模型 */}
|
||||
{/* 认证字段选择器 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeModel">
|
||||
{t("providerForm.anthropicModel", { defaultValue: "主模型" })}
|
||||
<FormLabel>
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeModel"
|
||||
type="text"
|
||||
value={claudeModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_MODEL", e.target.value)
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) =>
|
||||
onApiKeyFieldChange(v as ClaudeApiKeyField)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "",
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("providerForm.authFieldAuthToken", {
|
||||
defaultValue: "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("providerForm.authFieldApiKey", {
|
||||
defaultValue: "ANTHROPIC_API_KEY",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.authFieldHint", {
|
||||
defaultValue: "选择写入配置的认证环境变量名",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 推理模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="reasoningModel">
|
||||
{t("providerForm.anthropicReasoningModel")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="reasoningModel"
|
||||
type="text"
|
||||
value={reasoningModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* 模型映射 */}
|
||||
<div className="space-y-1 pt-2 border-t">
|
||||
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.modelMappingHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 主模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeModel">
|
||||
{t("providerForm.anthropicModel", {
|
||||
defaultValue: "主模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeModel",
|
||||
claudeModel,
|
||||
"ANTHROPIC_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Haiku */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
||||
{t("providerForm.anthropicDefaultHaikuModel", {
|
||||
defaultValue: "Haiku 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultHaikuModel"
|
||||
type="text"
|
||||
value={defaultHaikuModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.haikuModelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{/* 推理模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="reasoningModel">
|
||||
{t("providerForm.anthropicReasoningModel")}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"reasoningModel",
|
||||
reasoningModel,
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Sonnet */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultSonnetModel">
|
||||
{t("providerForm.anthropicDefaultSonnetModel", {
|
||||
defaultValue: "Sonnet 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultSonnetModel"
|
||||
type="text"
|
||||
value={defaultSonnetModel}
|
||||
onChange={(e) =>
|
||||
onModelChange(
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{/* 默认 Haiku */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
||||
{t("providerForm.anthropicDefaultHaikuModel", {
|
||||
defaultValue: "Haiku 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultHaikuModel",
|
||||
defaultHaikuModel,
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
t("providerForm.haikuModelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Opus */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultOpusModel">
|
||||
{t("providerForm.anthropicDefaultOpusModel", {
|
||||
defaultValue: "Opus 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="claudeDefaultOpusModel"
|
||||
type="text"
|
||||
value={defaultOpusModel}
|
||||
onChange={(e) =>
|
||||
onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", e.target.value)
|
||||
}
|
||||
placeholder={t("providerForm.modelPlaceholder", {
|
||||
defaultValue: "",
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* 默认 Sonnet */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultSonnetModel">
|
||||
{t("providerForm.anthropicDefaultSonnetModel", {
|
||||
defaultValue: "Sonnet 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultSonnetModel",
|
||||
defaultSonnetModel,
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 默认 Opus */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultOpusModel">
|
||||
{t("providerForm.anthropicDefaultOpusModel", {
|
||||
defaultValue: "Opus 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultOpusModel",
|
||||
defaultOpusModel,
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.modelHelper", {
|
||||
defaultValue:
|
||||
"可选:指定默认使用的 Claude 模型,留空则使用系统默认。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import {
|
||||
extractCodexTopLevelInt,
|
||||
setCodexTopLevelInt,
|
||||
removeCodexTopLevelField,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
interface CodexAuthSectionProps {
|
||||
value: string;
|
||||
@@ -115,6 +120,95 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Mirror value prop to local state (same pattern as CommonConfigEditor)
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const localValueRef = useRef(value);
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
localValueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
const handleLocalChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (newValue === localValueRef.current) return;
|
||||
localValueRef.current = newValue;
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Parse toggle states from TOML text
|
||||
const toggleStates = useMemo(() => {
|
||||
const contextWindow = extractCodexTopLevelInt(
|
||||
localValue,
|
||||
"model_context_window",
|
||||
);
|
||||
const compactLimit = extractCodexTopLevelInt(
|
||||
localValue,
|
||||
"model_auto_compact_token_limit",
|
||||
);
|
||||
return {
|
||||
contextWindow1M: contextWindow === 1000000,
|
||||
compactLimit: compactLimit ?? 900000,
|
||||
};
|
||||
}, [localValue]);
|
||||
|
||||
// Debounce timer for compact limit input
|
||||
const compactTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleContextWindowToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
let toml = localValueRef.current || "";
|
||||
if (checked) {
|
||||
toml = setCodexTopLevelInt(toml, "model_context_window", 1000000);
|
||||
// Auto-set compact limit if not already present
|
||||
if (
|
||||
extractCodexTopLevelInt(toml, "model_auto_compact_token_limit") ===
|
||||
undefined
|
||||
) {
|
||||
toml = setCodexTopLevelInt(
|
||||
toml,
|
||||
"model_auto_compact_token_limit",
|
||||
900000,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toml = removeCodexTopLevelField(toml, "model_context_window");
|
||||
toml = removeCodexTopLevelField(
|
||||
toml,
|
||||
"model_auto_compact_token_limit",
|
||||
);
|
||||
}
|
||||
handleLocalChange(toml);
|
||||
},
|
||||
[handleLocalChange],
|
||||
);
|
||||
|
||||
const handleCompactLimitChange = useCallback(
|
||||
(inputValue: string) => {
|
||||
clearTimeout(compactTimerRef.current);
|
||||
compactTimerRef.current = setTimeout(() => {
|
||||
const num = parseInt(inputValue, 10);
|
||||
if (!Number.isNaN(num) && num > 0) {
|
||||
handleLocalChange(
|
||||
setCodexTopLevelInt(
|
||||
localValueRef.current || "",
|
||||
"model_auto_compact_token_limit",
|
||||
num,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
[handleLocalChange],
|
||||
);
|
||||
|
||||
// Cleanup debounce timer
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(compactTimerRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -152,9 +246,34 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.contextWindow1M}
|
||||
onChange={(e) => handleContextWindowToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("codexConfig.contextWindow1M")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("codexConfig.autoCompactLimit")}:</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
key={toggleStates.compactLimit}
|
||||
defaultValue={toggleStates.compactLimit}
|
||||
disabled={!toggleStates.contextWindow1M}
|
||||
onChange={(e) => handleCompactLimitChange(e.target.value)}
|
||||
className="w-28 h-7 px-2 text-sm rounded border border-border bg-background text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
value={localValue}
|
||||
onChange={handleLocalChange}
|
||||
placeholder=""
|
||||
darkMode={isDarkMode}
|
||||
rows={8}
|
||||
|
||||
@@ -22,6 +22,8 @@ interface CodexFormFieldsProps {
|
||||
shouldShowSpeedTest: boolean;
|
||||
codexBaseUrl: string;
|
||||
onBaseUrlChange: (url: string) => void;
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
@@ -49,6 +51,8 @@ export function CodexFormFields({
|
||||
shouldShowSpeedTest,
|
||||
codexBaseUrl,
|
||||
onBaseUrlChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
@@ -93,6 +97,9 @@ export function CodexFormFields({
|
||||
onChange={onBaseUrlChange}
|
||||
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
|
||||
hint={t("providerForm.codexApiHint")}
|
||||
showFullUrlToggle
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -75,16 +75,24 @@ export function CommonConfigEditor({
|
||||
return {
|
||||
hideAttribution:
|
||||
config?.attribution?.commit === "" && config?.attribution?.pr === "",
|
||||
alwaysThinking: config?.alwaysThinkingEnabled === true,
|
||||
teammates:
|
||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
|
||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
|
||||
enableToolSearch:
|
||||
config?.env?.ENABLE_TOOL_SEARCH === "true" ||
|
||||
config?.env?.ENABLE_TOOL_SEARCH === "1",
|
||||
effortHigh: config?.effortLevel === "high",
|
||||
disableAutoUpgrade:
|
||||
config?.env?.DISABLE_AUTOUPDATER === "1" ||
|
||||
config?.env?.DISABLE_AUTOUPDATER === 1,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
hideAttribution: false,
|
||||
alwaysThinking: false,
|
||||
teammates: false,
|
||||
enableToolSearch: false,
|
||||
effortHigh: false,
|
||||
disableAutoUpgrade: false,
|
||||
};
|
||||
}
|
||||
}, [localValue]);
|
||||
@@ -102,13 +110,6 @@ export function CommonConfigEditor({
|
||||
delete config.attribution;
|
||||
}
|
||||
break;
|
||||
case "alwaysThinking":
|
||||
if (checked) {
|
||||
config.alwaysThinkingEnabled = true;
|
||||
} else {
|
||||
delete config.alwaysThinkingEnabled;
|
||||
}
|
||||
break;
|
||||
case "teammates":
|
||||
if (!config.env) config.env = {};
|
||||
if (checked) {
|
||||
@@ -118,6 +119,31 @@ export function CommonConfigEditor({
|
||||
if (Object.keys(config.env).length === 0) delete config.env;
|
||||
}
|
||||
break;
|
||||
case "enableToolSearch":
|
||||
if (!config.env) config.env = {};
|
||||
if (checked) {
|
||||
config.env.ENABLE_TOOL_SEARCH = "true";
|
||||
} else {
|
||||
delete config.env.ENABLE_TOOL_SEARCH;
|
||||
if (Object.keys(config.env).length === 0) delete config.env;
|
||||
}
|
||||
break;
|
||||
case "effortHigh":
|
||||
if (checked) {
|
||||
config.effortLevel = "high";
|
||||
} else {
|
||||
delete config.effortLevel;
|
||||
}
|
||||
break;
|
||||
case "disableAutoUpgrade":
|
||||
if (!config.env) config.env = {};
|
||||
if (checked) {
|
||||
config.env.DISABLE_AUTOUPDATER = "1";
|
||||
} else {
|
||||
delete config.env.DISABLE_AUTOUPDATER;
|
||||
if (Object.keys(config.env).length === 0) delete config.env;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
handleLocalChange(JSON.stringify(config, null, 2));
|
||||
@@ -178,15 +204,6 @@ export function CommonConfigEditor({
|
||||
/>
|
||||
<span>{t("claudeConfig.hideAttribution")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.alwaysThinking}
|
||||
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.alwaysThinking")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -196,6 +213,37 @@ export function CommonConfigEditor({
|
||||
/>
|
||||
<span>{t("claudeConfig.enableTeammates")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.enableToolSearch}
|
||||
onChange={(e) =>
|
||||
handleToggle("enableToolSearch", e.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.enableToolSearch")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.effortHigh}
|
||||
onChange={(e) => handleToggle("effortHigh", e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.effortHigh")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.disableAutoUpgrade}
|
||||
onChange={(e) =>
|
||||
handleToggle("disableAutoUpgrade", e.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.disableAutoUpgrade")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<JsonEditor
|
||||
value={localValue}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
Github,
|
||||
LogOut,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
X,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCopilotAuth } from "./hooks/useCopilotAuth";
|
||||
import type { GitHubAccount } from "@/lib/api";
|
||||
|
||||
interface CopilotAuthSectionProps {
|
||||
className?: string;
|
||||
/** 当前选中的 GitHub 账号 ID */
|
||||
selectedAccountId?: string | null;
|
||||
/** 账号选择回调 */
|
||||
onAccountSelect?: (accountId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot OAuth 认证区块
|
||||
*
|
||||
* 显示 GitHub Copilot 的认证状态,支持多账号管理和选择。
|
||||
*/
|
||||
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
|
||||
className,
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const {
|
||||
accounts,
|
||||
defaultAccountId,
|
||||
migrationError,
|
||||
hasAnyAccount,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
error,
|
||||
isPolling,
|
||||
isAddingAccount,
|
||||
isRemovingAccount,
|
||||
isSettingDefaultAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
setDefaultAccount,
|
||||
cancelAuth,
|
||||
logout,
|
||||
} = useCopilotAuth();
|
||||
|
||||
// 复制用户码
|
||||
const copyUserCode = async () => {
|
||||
if (deviceCode?.user_code) {
|
||||
await navigator.clipboard.writeText(deviceCode.user_code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理账号选择
|
||||
const handleAccountSelect = (value: string) => {
|
||||
onAccountSelect?.(value === "none" ? null : value);
|
||||
};
|
||||
|
||||
// 处理移除账号
|
||||
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
removeAccount(accountId);
|
||||
// 如果移除的是当前选中的账号,清除选择
|
||||
if (selectedAccountId === accountId) {
|
||||
onAccountSelect?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染账号头像
|
||||
const renderAvatar = (account: GitHubAccount) => {
|
||||
return <CopilotAccountAvatar account={account} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className || ""}`}>
|
||||
{/* 认证状态标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
|
||||
<Badge
|
||||
variant={hasAnyAccount ? "default" : "secondary"}
|
||||
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
|
||||
>
|
||||
{hasAnyAccount
|
||||
? t("copilot.accountCount", {
|
||||
count: accounts.length,
|
||||
defaultValue: `${accounts.length} 个账号`,
|
||||
})
|
||||
: t("copilot.notAuthenticated", "未认证")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{migrationError && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{t("copilot.migrationFailed", {
|
||||
error: migrationError,
|
||||
defaultValue: `旧认证数据迁移失败:${migrationError}`,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 账号选择器(有账号时显示) */}
|
||||
{hasAnyAccount && onAccountSelect && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("copilot.selectAccount", "选择账号")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedAccountId || "none"}
|
||||
onValueChange={handleAccountSelect}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"copilot.selectAccountPlaceholder",
|
||||
"选择一个 GitHub 账号",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<span className="text-muted-foreground">
|
||||
{t("copilot.useDefaultAccount", "使用默认账号")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderAvatar(account)}
|
||||
<span>{account.login}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已登录账号列表 */}
|
||||
{hasAnyAccount && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("copilot.loggedInAccounts", "已登录账号")}
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderAvatar(account)}
|
||||
<span className="text-sm font-medium">{account.login}</span>
|
||||
{defaultAccountId === account.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("copilot.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountId === account.id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("copilot.selected", "已选中")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{defaultAccountId !== account.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setDefaultAccount(account.id)}
|
||||
disabled={isSettingDefaultAccount}
|
||||
>
|
||||
{t("copilot.setAsDefault", "设为默认")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||
disabled={isRemovingAccount}
|
||||
title={t("copilot.removeAccount", "移除账号")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未认证状态 - 登录按钮 */}
|
||||
{!hasAnyAccount && pollingState === "idle" && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 已有账号 - 添加更多账号按钮 */}
|
||||
{hasAnyAccount && pollingState === "idle" && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isAddingAccount}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("copilot.addAnotherAccount", "添加其他账号")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 轮询中状态 */}
|
||||
{isPolling && deviceCode && (
|
||||
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("copilot.waitingForAuth", "等待授权中...")}
|
||||
</div>
|
||||
|
||||
{/* 用户码 */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{t("copilot.enterCode", "在浏览器中输入以下代码:")}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border">
|
||||
{deviceCode.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={copyUserCode}
|
||||
title={t("copilot.copyCode", "复制代码")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证链接 */}
|
||||
<div className="text-center">
|
||||
<a
|
||||
href={deviceCode.verification_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline"
|
||||
>
|
||||
{deviceCode.verification_uri}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 取消按钮 */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelAuth}
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{pollingState === "error" && error && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addAccount}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{t("copilot.retry", "重试")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={cancelAuth}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注销所有账号按钮 */}
|
||||
{hasAnyAccount && accounts.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={logout}
|
||||
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("copilot.logoutAll", "注销所有账号")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CopilotAccountAvatar: React.FC<{ account: GitHubAccount }> = ({
|
||||
account,
|
||||
}) => {
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
|
||||
if (!account.avatar_url || failed) {
|
||||
return <User className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.login}
|
||||
className="h-5 w-5 rounded-full"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopilotAuthSection;
|
||||
@@ -14,6 +14,10 @@ import { Plus, Trash2, ChevronRight } from "lucide-react";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import { opencodeNpmPackages } from "@/config/opencodeProviderPresets";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getModelExtraFields,
|
||||
isKnownModelKey,
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import type { ProviderCategory, OpenCodeModel } from "@/types";
|
||||
|
||||
/**
|
||||
@@ -121,6 +125,10 @@ function ModelOptionKeyInput({
|
||||
if (trimmed && trimmed !== optionKey) {
|
||||
onChange(trimmed);
|
||||
}
|
||||
// Reset to prop value: if parent accepted the rename, useEffect
|
||||
// will update localValue when the new optionKey prop arrives;
|
||||
// if parent rejected, this restores the correct display.
|
||||
setLocalValue(optionKey.startsWith("option-") ? "" : optionKey);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
@@ -305,6 +313,65 @@ export function OpenCodeFormFields({
|
||||
});
|
||||
};
|
||||
|
||||
// Model extra field handlers (top-level properties like variants, cost)
|
||||
const handleAddModelExtraField = (modelKey: string) => {
|
||||
const model = models[modelKey];
|
||||
const newFieldKey = `option-${Date.now()}`;
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: { ...model, [newFieldKey]: "" },
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveModelExtraField = (modelKey: string, fieldKey: string) => {
|
||||
const model = models[modelKey];
|
||||
const newModel = { ...model };
|
||||
delete newModel[fieldKey];
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: newModel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelExtraFieldKeyChange = (
|
||||
modelKey: string,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
) => {
|
||||
if (!newKey.trim() || oldKey === newKey) return;
|
||||
const model = models[modelKey];
|
||||
// Reject reserved keys and duplicate extra field names
|
||||
if (isKnownModelKey(newKey) || (newKey !== oldKey && newKey in model))
|
||||
return;
|
||||
const newModel: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(model)) {
|
||||
if (k === oldKey) newModel[newKey] = v;
|
||||
else newModel[k] = v;
|
||||
}
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: newModel as OpenCodeModel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelExtraFieldValueChange = (
|
||||
modelKey: string,
|
||||
fieldKey: string,
|
||||
value: string,
|
||||
) => {
|
||||
const model = models[modelKey];
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = value;
|
||||
}
|
||||
onModelsChange({
|
||||
...models,
|
||||
[modelKey]: { ...model, [fieldKey]: parsedValue },
|
||||
});
|
||||
};
|
||||
|
||||
// Extra Options handlers
|
||||
const handleAddExtraOption = () => {
|
||||
const newKey = `option-${Date.now()}`;
|
||||
@@ -559,16 +626,96 @@ export function OpenCodeFormFields({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expanded model options */}
|
||||
{/* Expanded model details */}
|
||||
{expandedModels.has(key) && (
|
||||
<div className="ml-9 pl-4 border-l-2 border-muted space-y-2">
|
||||
{Object.keys(model.options || {}).length === 0 ? (
|
||||
<div className="ml-9 pl-4 border-l-2 border-muted space-y-3">
|
||||
{/* Model Properties (extra fields like variants, cost) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("opencode.modelExtraFields", {
|
||||
defaultValue: "模型属性",
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddModelExtraField(key)}
|
||||
className="h-6 px-2 gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(getModelExtraFields(model)).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-1">
|
||||
{t("opencode.noModelOptions", {
|
||||
defaultValue: "模型选项,点击 + 添加",
|
||||
{t("opencode.noModelExtraFields", {
|
||||
defaultValue:
|
||||
"模型属性 (variants, cost 等),点击 + 添加",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(getModelExtraFields(model)).map(
|
||||
([fKey, fValue]) => (
|
||||
<div key={fKey} className="flex items-center gap-2">
|
||||
<ModelOptionKeyInput
|
||||
optionKey={fKey}
|
||||
onChange={(newKey) =>
|
||||
handleModelExtraFieldKeyChange(
|
||||
key,
|
||||
fKey,
|
||||
newKey,
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"opencode.modelExtraFieldKeyPlaceholder",
|
||||
{
|
||||
defaultValue: "variants",
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={fValue}
|
||||
onChange={(e) =>
|
||||
handleModelExtraFieldValueChange(
|
||||
key,
|
||||
fKey,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"opencode.modelOptionValuePlaceholder",
|
||||
{
|
||||
defaultValue: '{"order": ["baseten"]}',
|
||||
},
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleRemoveModelExtraField(key, fKey)
|
||||
}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SDK Options (model.options) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("opencode.sdkOptions", {
|
||||
defaultValue: "SDK 选项",
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -579,9 +726,14 @@ export function OpenCodeFormFields({
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(model.options || {}).map(
|
||||
{Object.keys(model.options || {}).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-1">
|
||||
{t("opencode.noModelOptions", {
|
||||
defaultValue: "模型选项,点击 + 添加",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(model.options || {}).map(
|
||||
([optKey, optValue]) => (
|
||||
<div
|
||||
key={optKey}
|
||||
@@ -637,20 +789,9 @@ export function OpenCodeFormFields({
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddModelOption(key)}
|
||||
className="h-6 px-2 gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
useOpencodeFormState,
|
||||
useOmoDraftState,
|
||||
useOpenclawFormState,
|
||||
useCopilotAuth,
|
||||
} from "./hooks";
|
||||
import {
|
||||
CLAUDE_DEFAULT_CONFIG,
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
OPENCLAW_DEFAULT_CONFIG,
|
||||
normalizePricingSource,
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -104,10 +106,11 @@ interface ProviderFormProps {
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
submitLabel: string;
|
||||
onSubmit: (values: ProviderFormValues) => void;
|
||||
onSubmit: (values: ProviderFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
|
||||
onManageUniversalProviders?: () => void;
|
||||
onSubmittingChange?: (isSubmitting: boolean) => void;
|
||||
initialData?: {
|
||||
name?: string;
|
||||
websiteUrl?: string;
|
||||
@@ -129,6 +132,7 @@ export function ProviderForm({
|
||||
onCancel,
|
||||
onUniversalPresetSelect,
|
||||
onManageUniversalProviders,
|
||||
onSubmittingChange,
|
||||
initialData,
|
||||
showButtons = true,
|
||||
}: ProviderFormProps) {
|
||||
@@ -158,6 +162,11 @@ export function ProviderForm({
|
||||
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||
);
|
||||
const supportsFullUrl = appId === "claude" || appId === "codex";
|
||||
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
|
||||
if (!supportsFullUrl) return false;
|
||||
return initialData?.meta?.isFullUrl ?? false;
|
||||
});
|
||||
|
||||
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
|
||||
() => initialData?.meta?.testConfig ?? { enabled: false },
|
||||
@@ -197,6 +206,9 @@ export function ProviderForm({
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
setLocalIsFullUrl(
|
||||
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
|
||||
);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||
setPricingConfig({
|
||||
@@ -208,7 +220,7 @@ export function ProviderForm({
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
});
|
||||
}, [appId, initialData]);
|
||||
}, [appId, initialData, supportsFullUrl]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
() => ({
|
||||
@@ -237,6 +249,7 @@ export function ProviderForm({
|
||||
defaultValues,
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
const handleSettingsConfigChange = useCallback(
|
||||
(config: string) => {
|
||||
@@ -257,6 +270,10 @@ export function ProviderForm({
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmittingChange?.(isSubmitting);
|
||||
}, [isSubmitting, onSubmittingChange]);
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
handleApiKeyChange,
|
||||
@@ -324,6 +341,14 @@ export function ProviderForm({
|
||||
[localApiKeyField, form, handleSettingsConfigChange],
|
||||
);
|
||||
|
||||
// Copilot OAuth 认证状态(仅 Claude 应用需要)
|
||||
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
|
||||
|
||||
// 选中的 GitHub 账号 ID(多账号支持)
|
||||
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
|
||||
string | null
|
||||
>(() => resolveManagedAccountId(initialData?.meta, "github_copilot"));
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
@@ -583,7 +608,7 @@ export function ProviderForm({
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (values: ProviderFormData) => {
|
||||
const handleSubmit = async (values: ProviderFormData) => {
|
||||
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||
const validation = validateTemplateValues();
|
||||
if (!validation.isValid && validation.missingField) {
|
||||
@@ -653,6 +678,21 @@ export function ProviderForm({
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
|
||||
// GitHub Copilot 使用 OAuth 认证,不需要 API Key
|
||||
const isCopilotProvider =
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com");
|
||||
// GitHub Copilot 必须先登录才能添加
|
||||
if (isCopilotProvider && !isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
t("copilot.loginRequired", {
|
||||
defaultValue: "请先登录 GitHub Copilot",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (category !== "official" && category !== "cloud_provider") {
|
||||
if (appId === "claude") {
|
||||
if (!baseUrl.trim()) {
|
||||
@@ -663,7 +703,7 @@ export function ProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
if (!isCopilotProvider && !apiKey.trim()) {
|
||||
toast.error(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
@@ -860,6 +900,11 @@ export function ProviderForm({
|
||||
|
||||
const baseMeta: ProviderMeta | undefined =
|
||||
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
|
||||
|
||||
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
||||
const providerType =
|
||||
templatePreset?.providerType || initialData?.meta?.providerType;
|
||||
|
||||
payload.meta = {
|
||||
...(baseMeta ?? {}),
|
||||
commonConfigEnabled:
|
||||
@@ -871,6 +916,20 @@ export function ProviderForm({
|
||||
? useGeminiCommonConfigFlag
|
||||
: undefined,
|
||||
endpointAutoSelect,
|
||||
// 保存 providerType(用于识别 Copilot 等特殊供应商)
|
||||
providerType,
|
||||
authBinding: isCopilotProvider
|
||||
? {
|
||||
source: "managed_account",
|
||||
authProvider: "github_copilot",
|
||||
accountId: selectedGitHubAccountId ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
// GitHub Copilot 多账号:保存关联的账号 ID
|
||||
githubAccountId:
|
||||
isCopilotProvider && selectedGitHubAccountId
|
||||
? selectedGitHubAccountId
|
||||
: undefined,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
@@ -890,9 +949,13 @@ export function ProviderForm({
|
||||
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localApiKeyField
|
||||
: undefined,
|
||||
isFullUrl:
|
||||
supportsFullUrl && category !== "official" && localIsFullUrl
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
const groupedPresets = useMemo(() => {
|
||||
@@ -1129,6 +1192,7 @@ export function ProviderForm({
|
||||
}
|
||||
|
||||
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
|
||||
setLocalIsFullUrl(false);
|
||||
|
||||
form.reset({
|
||||
name: preset.nameKey ? t(preset.nameKey) : preset.name,
|
||||
@@ -1311,6 +1375,20 @@ export function ProviderForm({
|
||||
websiteUrl={claudeWebsiteUrl}
|
||||
isPartner={isClaudePartner}
|
||||
partnerPromotionKey={claudePartnerPromotionKey}
|
||||
isCopilotPreset={
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
usesOAuth={
|
||||
templatePreset?.requiresOAuth === true ||
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
isCopilotAuthenticated={isCopilotAuthenticated}
|
||||
selectedGitHubAccountId={selectedGitHubAccountId}
|
||||
onGitHubAccountSelect={setSelectedGitHubAccountId}
|
||||
templateValueEntries={templateValueEntries}
|
||||
templateValues={templateValues}
|
||||
templatePresetName={templatePreset?.name || ""}
|
||||
@@ -1337,6 +1415,8 @@ export function ProviderForm({
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
apiKeyField={localApiKeyField}
|
||||
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1353,6 +1433,8 @@ export function ProviderForm({
|
||||
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||
codexBaseUrl={codexBaseUrl}
|
||||
onBaseUrlChange={handleCodexBaseUrlChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
isEndpointModalOpen={isCodexEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsCodexEndpointModalOpen}
|
||||
onCustomEndpointsChange={
|
||||
@@ -1600,7 +1682,9 @@ export function ProviderForm({
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{submitLabel}</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -28,6 +28,7 @@ export const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
@@ -109,6 +110,26 @@ export function parseOpencodeConfigStrict(
|
||||
};
|
||||
}
|
||||
|
||||
export const OPENCODE_KNOWN_MODEL_KEYS = ["name", "limit", "options"] as const;
|
||||
|
||||
export function isKnownModelKey(key: string): boolean {
|
||||
return OPENCODE_KNOWN_MODEL_KEYS.includes(
|
||||
key as (typeof OPENCODE_KNOWN_MODEL_KEYS)[number],
|
||||
);
|
||||
}
|
||||
|
||||
export function getModelExtraFields(
|
||||
model: OpenCodeModel,
|
||||
): Record<string, string> {
|
||||
const extra: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(model)) {
|
||||
if (!isKnownModelKey(k)) {
|
||||
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||
}
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
export function toOpencodeExtraOptions(
|
||||
options: OpenCodeProviderConfig["options"],
|
||||
): Record<string, string> {
|
||||
|
||||
@@ -11,8 +11,10 @@ export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||
export { useManagedAuth } from "./useManagedAuth";
|
||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
||||
export { useOmoModelSource } from "./useOmoModelSource";
|
||||
export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||
export { useOmoDraftState } from "./useOmoDraftState";
|
||||
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||
export { useCopilotAuth } from "./useCopilotAuth";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GitHubAccount } from "@/lib/api";
|
||||
import { useManagedAuth } from "./useManagedAuth";
|
||||
|
||||
export function useCopilotAuth() {
|
||||
const managedAuth = useManagedAuth("github_copilot");
|
||||
const defaultAccount =
|
||||
managedAuth.accounts.find(
|
||||
(account) => account.id === managedAuth.defaultAccountId,
|
||||
) ?? managedAuth.accounts[0];
|
||||
|
||||
return {
|
||||
...managedAuth,
|
||||
authStatus: managedAuth.authStatus
|
||||
? {
|
||||
authenticated: managedAuth.authStatus.authenticated,
|
||||
username: defaultAccount?.login ?? null,
|
||||
// Managed auth status does not expose a single provider-wide token expiry.
|
||||
expires_at: null,
|
||||
default_account_id: managedAuth.defaultAccountId,
|
||||
migration_error: managedAuth.migrationError,
|
||||
accounts: managedAuth.accounts as GitHubAccount[],
|
||||
}
|
||||
: undefined,
|
||||
// Managed auth status no longer exposes a single default token expiry.
|
||||
username: defaultAccount?.login ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { authApi, settingsApi } from "@/lib/api";
|
||||
import type {
|
||||
ManagedAuthProvider,
|
||||
ManagedAuthStatus,
|
||||
ManagedAuthDeviceCodeResponse,
|
||||
} from "@/lib/api";
|
||||
|
||||
type PollingState = "idle" | "polling" | "success" | "error";
|
||||
|
||||
export function useManagedAuth(authProvider: ManagedAuthProvider) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["managed-auth-status", authProvider];
|
||||
|
||||
const [pollingState, setPollingState] = useState<PollingState>("idle");
|
||||
const [deviceCode, setDeviceCode] =
|
||||
useState<ManagedAuthDeviceCodeResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
);
|
||||
const pollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const {
|
||||
data: authStatus,
|
||||
isLoading: isLoadingStatus,
|
||||
refetch: refetchStatus,
|
||||
} = useQuery<ManagedAuthStatus>({
|
||||
queryKey,
|
||||
queryFn: () => authApi.authGetStatus(authProvider),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
if (pollingTimeoutRef.current) {
|
||||
clearTimeout(pollingTimeoutRef.current);
|
||||
pollingTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
const startLoginMutation = useMutation({
|
||||
mutationFn: () => authApi.authStartLogin(authProvider),
|
||||
onSuccess: async (response) => {
|
||||
setDeviceCode(response);
|
||||
setPollingState("polling");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(response.user_code);
|
||||
} catch (e) {
|
||||
console.debug("[ManagedAuth] Failed to copy user code:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
await settingsApi.openExternal(response.verification_uri);
|
||||
} catch (e) {
|
||||
console.debug("[ManagedAuth] Failed to open browser:", e);
|
||||
}
|
||||
|
||||
// Add a small buffer on top of GitHub's suggested interval to avoid
|
||||
// hitting slow_down responses too aggressively during device polling.
|
||||
const interval = Math.max((response.interval || 5) + 3, 8) * 1000;
|
||||
const expiresAt = Date.now() + response.expires_in * 1000;
|
||||
|
||||
const pollOnce = async () => {
|
||||
if (Date.now() > expiresAt) {
|
||||
stopPolling();
|
||||
setPollingState("error");
|
||||
setError("Device code expired. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newAccount = await authApi.authPollForAccount(
|
||||
authProvider,
|
||||
response.device_code,
|
||||
);
|
||||
if (newAccount) {
|
||||
stopPolling();
|
||||
setPollingState("success");
|
||||
await refetchStatus();
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (
|
||||
!errorMessage.includes("pending") &&
|
||||
!errorMessage.includes("slow_down")
|
||||
) {
|
||||
stopPolling();
|
||||
setPollingState("error");
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void pollOnce();
|
||||
pollingIntervalRef.current = setInterval(pollOnce, interval);
|
||||
pollingTimeoutRef.current = setTimeout(() => {
|
||||
stopPolling();
|
||||
setPollingState("error");
|
||||
setError("Device code expired. Please try again.");
|
||||
}, response.expires_in * 1000);
|
||||
},
|
||||
onError: (e) => {
|
||||
setPollingState("error");
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => authApi.authLogout(authProvider),
|
||||
onSuccess: async () => {
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
queryClient.setQueryData(queryKey, {
|
||||
provider: authProvider,
|
||||
authenticated: false,
|
||||
default_account_id: null,
|
||||
accounts: [],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: async (e) => {
|
||||
console.error("[ManagedAuth] Failed to logout:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
await refetchStatus();
|
||||
},
|
||||
});
|
||||
|
||||
const removeAccountMutation = useMutation({
|
||||
mutationFn: (accountId: string) =>
|
||||
authApi.authRemoveAccount(authProvider, accountId),
|
||||
onSuccess: async () => {
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
await refetchStatus();
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error("[ManagedAuth] Failed to remove account:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
});
|
||||
|
||||
const setDefaultAccountMutation = useMutation({
|
||||
mutationFn: (accountId: string) =>
|
||||
authApi.authSetDefaultAccount(authProvider, accountId),
|
||||
onSuccess: async () => {
|
||||
await refetchStatus();
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error("[ManagedAuth] Failed to set default account:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
});
|
||||
|
||||
const startAuth = useCallback(() => {
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
stopPolling();
|
||||
startLoginMutation.mutate();
|
||||
}, [startLoginMutation, stopPolling]);
|
||||
|
||||
const cancelAuth = useCallback(() => {
|
||||
stopPolling();
|
||||
setPollingState("idle");
|
||||
setDeviceCode(null);
|
||||
setError(null);
|
||||
}, [stopPolling]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate();
|
||||
}, [logoutMutation]);
|
||||
|
||||
const removeAccount = useCallback(
|
||||
(accountId: string) => {
|
||||
removeAccountMutation.mutate(accountId);
|
||||
},
|
||||
[removeAccountMutation],
|
||||
);
|
||||
|
||||
const setDefaultAccount = useCallback(
|
||||
(accountId: string) => {
|
||||
setDefaultAccountMutation.mutate(accountId);
|
||||
},
|
||||
[setDefaultAccountMutation],
|
||||
);
|
||||
|
||||
const accounts = authStatus?.accounts ?? [];
|
||||
|
||||
return {
|
||||
authStatus,
|
||||
isLoadingStatus,
|
||||
accounts,
|
||||
hasAnyAccount: accounts.length > 0,
|
||||
isAuthenticated: authStatus?.authenticated ?? false,
|
||||
defaultAccountId: authStatus?.default_account_id ?? null,
|
||||
migrationError: authStatus?.migration_error ?? null,
|
||||
pollingState,
|
||||
deviceCode,
|
||||
error,
|
||||
isPolling: pollingState === "polling",
|
||||
isAddingAccount: startLoginMutation.isPending || pollingState === "polling",
|
||||
isRemovingAccount: removeAccountMutation.isPending,
|
||||
isSettingDefaultAccount: setDefaultAccountMutation.isPending,
|
||||
startAuth,
|
||||
addAccount: startAuth,
|
||||
cancelAuth,
|
||||
logout,
|
||||
removeAccount,
|
||||
setDefaultAccount,
|
||||
refetchStatus,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Link2, Zap } from "lucide-react";
|
||||
|
||||
interface EndpointFieldProps {
|
||||
id: string;
|
||||
@@ -13,6 +14,9 @@ interface EndpointFieldProps {
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
showFullUrlToggle?: boolean;
|
||||
isFullUrl?: boolean;
|
||||
onFullUrlChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function EndpointField({
|
||||
@@ -25,18 +29,56 @@ export function EndpointField({
|
||||
showManageButton = true,
|
||||
onManageClick,
|
||||
manageButtonLabel,
|
||||
showFullUrlToggle = false,
|
||||
isFullUrl = false,
|
||||
onFullUrlChange,
|
||||
}: EndpointFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const defaultManageLabel = t("providerForm.manageAndTest", {
|
||||
defaultValue: "管理和测速",
|
||||
});
|
||||
const effectiveHint =
|
||||
showFullUrlToggle && isFullUrl
|
||||
? t("providerForm.fullUrlHint", {
|
||||
defaultValue:
|
||||
"💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
|
||||
})
|
||||
: hint;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel htmlFor={id}>{label}</FormLabel>
|
||||
{showManageButton && onManageClick && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FormLabel htmlFor={id}>{label}</FormLabel>
|
||||
{showFullUrlToggle && onFullUrlChange ? (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/70 bg-muted/30 px-2.5 py-1">
|
||||
<Link2
|
||||
className={`h-3.5 w-3.5 ${
|
||||
isFullUrl ? "text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isFullUrl ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("providerForm.fullUrlLabel", {
|
||||
defaultValue: "完整 URL",
|
||||
})}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isFullUrl}
|
||||
onCheckedChange={onFullUrlChange}
|
||||
aria-label={t("providerForm.fullUrlLabel", {
|
||||
defaultValue: "完整 URL",
|
||||
})}
|
||||
className="h-5 w-9"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showManageButton && onManageClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageClick}
|
||||
@@ -45,7 +87,7 @@ export function EndpointField({
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{manageButtonLabel || defaultManageLabel}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
@@ -55,9 +97,11 @@ export function EndpointField({
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{hint ? (
|
||||
{effectiveHint ? (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{effectiveHint}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -208,12 +208,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto px-4 sm:px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div
|
||||
className="mx-auto px-4 sm:px-6 flex flex-col h-full min-h-0"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||
{/* 主内容区域 - 左右分栏 */}
|
||||
<div className="flex-1 overflow-hidden grid gap-4 md:grid-cols-[320px_1fr]">
|
||||
{/* 左侧会话列表 */}
|
||||
<Card className="flex flex-col overflow-hidden">
|
||||
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<CardHeader className="py-2 px-3 border-b">
|
||||
{isSearchOpen ? (
|
||||
<div className="relative flex-1">
|
||||
@@ -387,7 +390,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<CardContent className="flex-1 min-h-0 p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
@@ -605,11 +608,11 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</CardHeader>
|
||||
|
||||
{/* 消息列表区域 */}
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div className="flex h-full">
|
||||
<CardContent className="flex-1 min-h-0 p-0">
|
||||
<div className="flex h-full min-w-0">
|
||||
{/* 消息列表 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
<ScrollArea className="flex-1 min-w-0">
|
||||
<div className="p-4 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SessionMessageItem({
|
||||
<div
|
||||
ref={setRef}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2.5 relative group transition-all",
|
||||
"rounded-lg border px-3 py-2.5 relative group transition-all min-w-0",
|
||||
message.role.toLowerCase() === "user"
|
||||
? "bg-primary/5 border-primary/20 ml-8"
|
||||
: message.role.toLowerCase() === "assistant"
|
||||
@@ -67,7 +67,7 @@ export function SessionMessageItem({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
<div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-relaxed min-w-0">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Github, ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
|
||||
|
||||
export function AuthCenterPanel() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.authCenter.title", {
|
||||
defaultValue: "OAuth 认证中心",
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.authCenter.description", {
|
||||
defaultValue:
|
||||
"集中管理跨应用复用的 OAuth 账号。Provider 只绑定这些认证源,不再重复登录。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t("settings.authCenter.beta", { defaultValue: "Beta" })}
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Github className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">GitHub Copilot</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.authCenter.copilotDescription", {
|
||||
defaultValue:
|
||||
"管理 GitHub Copilot 账号、默认账号以及供 Claude / Codex / Gemini 绑定的托管凭据。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CopilotAuthSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ScrollText,
|
||||
HardDriveDownload,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
||||
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -189,11 +191,14 @@ export function SettingsPage({
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5 mb-6 glass rounded-lg">
|
||||
<TabsList className="grid w-full grid-cols-6 mb-6 glass rounded-lg">
|
||||
<TabsTrigger value="general">
|
||||
{t("settings.tabGeneral")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
|
||||
<TabsTrigger value="auth">
|
||||
{t("settings.tabAuth", { defaultValue: "认证" })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
@@ -249,6 +254,34 @@ export function SettingsPage({
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-6 mt-0 pb-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("settings.authCenter.heading", {
|
||||
defaultValue: "认证中心",
|
||||
})}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.authCenter.headingDescription", {
|
||||
defaultValue:
|
||||
"统一管理可跨应用复用的 OAuth 账号和默认认证来源。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthCenterPanel />
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
||||
{settings ? (
|
||||
<motion.div
|
||||
|
||||
@@ -233,7 +233,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
}, [skills, searchQuery, filterRepo, filterStatus]);
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
|
||||
{/* 技能网格(可滚动详情区域) */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
|
||||
<div className="py-4">
|
||||
|
||||
@@ -4,7 +4,12 @@ import { Sparkles, Trash2, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type ImportSkillSelection,
|
||||
type SkillBackupEntry,
|
||||
useDeleteSkillBackup,
|
||||
useInstalledSkills,
|
||||
useSkillBackups,
|
||||
useRestoreSkillBackup,
|
||||
useToggleSkillApp,
|
||||
useUninstallSkill,
|
||||
useScanUnmanagedSkills,
|
||||
@@ -20,33 +25,60 @@ import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface UnifiedSkillsPanelProps {
|
||||
onOpenDiscovery: () => void;
|
||||
currentApp: AppId;
|
||||
}
|
||||
|
||||
export interface UnifiedSkillsPanelHandle {
|
||||
openDiscovery: () => void;
|
||||
openImport: () => void;
|
||||
openInstallFromZip: () => void;
|
||||
openRestoreFromBackup: () => void;
|
||||
}
|
||||
|
||||
function formatSkillBackupDate(unixSeconds: number): string {
|
||||
const date = new Date(unixSeconds * 1000);
|
||||
return Number.isNaN(date.getTime())
|
||||
? String(unixSeconds)
|
||||
: date.toLocaleString();
|
||||
}
|
||||
|
||||
const UnifiedSkillsPanel = React.forwardRef<
|
||||
UnifiedSkillsPanelHandle,
|
||||
UnifiedSkillsPanelProps
|
||||
>(({ onOpenDiscovery }, ref) => {
|
||||
>(({ onOpenDiscovery, currentApp }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
variant?: "destructive" | "info";
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
|
||||
const { data: skills, isLoading } = useInstalledSkills();
|
||||
const {
|
||||
data: skillBackups = [],
|
||||
refetch: refetchSkillBackups,
|
||||
isFetching: isFetchingSkillBackups,
|
||||
} = useSkillBackups();
|
||||
const deleteBackupMutation = useDeleteSkillBackup();
|
||||
const toggleAppMutation = useToggleSkillApp();
|
||||
const uninstallMutation = useUninstallSkill();
|
||||
const restoreBackupMutation = useRestoreSkillBackup();
|
||||
const { data: unmanagedSkills, refetch: scanUnmanaged } =
|
||||
useScanUnmanagedSkills();
|
||||
const importMutation = useImportSkillsFromApps();
|
||||
@@ -78,9 +110,21 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
message: t("skills.uninstallConfirm", { name: skill.name }),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await uninstallMutation.mutateAsync(skill.id);
|
||||
// 构建 skillKey 用于更新 discoverable 缓存
|
||||
const installName =
|
||||
skill.directory.split(/[/\\]/).pop()?.toLowerCase() ||
|
||||
skill.directory.toLowerCase();
|
||||
const skillKey = `${installName}:${skill.repoOwner?.toLowerCase() || ""}:${skill.repoName?.toLowerCase() || ""}`;
|
||||
|
||||
const result = await uninstallMutation.mutateAsync({
|
||||
id: skill.id,
|
||||
skillKey,
|
||||
});
|
||||
setConfirmDialog(null);
|
||||
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
|
||||
description: result.backupPath
|
||||
? t("skills.backup.location", { path: result.backupPath })
|
||||
: undefined,
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -103,9 +147,9 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (directories: string[]) => {
|
||||
const handleImport = async (imports: ImportSkillSelection[]) => {
|
||||
try {
|
||||
const imported = await importMutation.mutateAsync(directories);
|
||||
const imported = await importMutation.mutateAsync(imports);
|
||||
setImportDialogOpen(false);
|
||||
toast.success(t("skills.importSuccess", { count: imported.length }), {
|
||||
closeButton: true,
|
||||
@@ -120,7 +164,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const filePath = await skillsApi.openZipFileDialog();
|
||||
if (!filePath) return;
|
||||
|
||||
const currentApp: AppId = "claude";
|
||||
const installed = await installFromZipMutation.mutateAsync({
|
||||
filePath,
|
||||
currentApp,
|
||||
@@ -148,14 +191,75 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRestoreFromBackup = async () => {
|
||||
setRestoreDialogOpen(true);
|
||||
try {
|
||||
await refetchSkillBackups();
|
||||
} catch (error) {
|
||||
toast.error(t("common.error"), { description: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreFromBackup = async (backupId: string) => {
|
||||
try {
|
||||
const restored = await restoreBackupMutation.mutateAsync({
|
||||
backupId,
|
||||
currentApp,
|
||||
});
|
||||
setRestoreDialogOpen(false);
|
||||
toast.success(
|
||||
t("skills.restoreFromBackup.success", { name: restored.name }),
|
||||
{
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(t("skills.restoreFromBackup.failed"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBackup = (backup: SkillBackupEntry) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t("skills.restoreFromBackup.deleteConfirmTitle"),
|
||||
message: t("skills.restoreFromBackup.deleteConfirmMessage", {
|
||||
name: backup.skill.name,
|
||||
}),
|
||||
confirmText: t("skills.restoreFromBackup.delete"),
|
||||
variant: "destructive",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteBackupMutation.mutateAsync(backup.backupId);
|
||||
await refetchSkillBackups();
|
||||
setConfirmDialog(null);
|
||||
toast.success(
|
||||
t("skills.restoreFromBackup.deleteSuccess", {
|
||||
name: backup.skill.name,
|
||||
}),
|
||||
{
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(t("skills.restoreFromBackup.deleteFailed"), {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openDiscovery: onOpenDiscovery,
|
||||
openImport: handleOpenImport,
|
||||
openInstallFromZip: handleInstallFromZip,
|
||||
openRestoreFromBackup: handleOpenRestoreFromBackup,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<AppCountBar
|
||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||
counts={enabledCounts}
|
||||
@@ -201,6 +305,9 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
isOpen={confirmDialog.isOpen}
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
confirmText={confirmDialog.confirmText}
|
||||
variant={confirmDialog.variant}
|
||||
zIndex="top"
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
@@ -213,6 +320,17 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RestoreSkillsDialog
|
||||
backups={skillBackups}
|
||||
isDeleting={deleteBackupMutation.isPending}
|
||||
isLoading={isFetchingSkillBackups}
|
||||
onDelete={handleDeleteBackup}
|
||||
isRestoring={restoreBackupMutation.isPending}
|
||||
onRestore={handleRestoreFromBackup}
|
||||
onClose={() => setRestoreDialogOpen(false)}
|
||||
open={restoreDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -310,10 +428,128 @@ interface ImportSkillsDialogProps {
|
||||
foundIn: string[];
|
||||
path: string;
|
||||
}>;
|
||||
onImport: (directories: string[]) => void;
|
||||
onImport: (imports: ImportSkillSelection[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface RestoreSkillsDialogProps {
|
||||
backups: SkillBackupEntry[];
|
||||
isDeleting: boolean;
|
||||
isLoading: boolean;
|
||||
isRestoring: boolean;
|
||||
onDelete: (backup: SkillBackupEntry) => void;
|
||||
onRestore: (backupId: string) => void;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const RestoreSkillsDialog: React.FC<RestoreSkillsDialogProps> = ({
|
||||
backups,
|
||||
isDeleting,
|
||||
isLoading,
|
||||
isRestoring,
|
||||
onDelete,
|
||||
onRestore,
|
||||
onClose,
|
||||
open,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[85vh] flex flex-col"
|
||||
zIndex="alert"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("skills.restoreFromBackup.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("skills.restoreFromBackup.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("skills.restoreFromBackup.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.backupId}
|
||||
className="rounded-xl border border-border-default bg-background/70 p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-sm text-foreground">
|
||||
{backup.skill.name}
|
||||
</div>
|
||||
<div className="rounded-md bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{backup.skill.directory}
|
||||
</div>
|
||||
</div>
|
||||
{backup.skill.description && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{backup.skill.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 space-y-1.5 text-xs text-muted-foreground">
|
||||
<div>
|
||||
{t("skills.restoreFromBackup.createdAt")}:{" "}
|
||||
{formatSkillBackupDate(backup.createdAt)}
|
||||
</div>
|
||||
<div className="break-all" title={backup.backupPath}>
|
||||
{t("skills.restoreFromBackup.path")}:{" "}
|
||||
{backup.backupPath}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:min-w-28">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onRestore(backup.backupId)}
|
||||
disabled={isRestoring || isDeleting}
|
||||
>
|
||||
{isRestoring
|
||||
? t("skills.restoreFromBackup.restoring")
|
||||
: t("skills.restoreFromBackup.restore")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(backup)}
|
||||
disabled={isRestoring || isDeleting}
|
||||
>
|
||||
{isDeleting
|
||||
? t("skills.restoreFromBackup.deleting")
|
||||
: t("skills.restoreFromBackup.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
skills,
|
||||
onImport,
|
||||
@@ -323,6 +559,22 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
new Set(skills.map((s) => s.directory)),
|
||||
);
|
||||
const [selectedApps, setSelectedApps] = useState<
|
||||
Record<string, ImportSkillSelection["apps"]>
|
||||
>(() =>
|
||||
Object.fromEntries(
|
||||
skills.map((skill) => [
|
||||
skill.directory,
|
||||
{
|
||||
claude: skill.foundIn.includes("claude"),
|
||||
codex: skill.foundIn.includes("codex"),
|
||||
gemini: skill.foundIn.includes("gemini"),
|
||||
opencode: skill.foundIn.includes("opencode"),
|
||||
openclaw: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const toggleSelect = (directory: string) => {
|
||||
const newSelected = new Set(selected);
|
||||
@@ -335,57 +587,99 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
onImport(Array.from(selected));
|
||||
onImport(
|
||||
Array.from(selected).map((directory) => ({
|
||||
directory,
|
||||
apps: selectedApps[directory] ?? {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-2">{t("skills.import")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("skills.importDescription")}
|
||||
</p>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-2">{t("skills.import")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("skills.importDescription")}
|
||||
</p>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
|
||||
{skills.map((skill) => (
|
||||
<label
|
||||
key={skill.directory}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(skill.directory)}
|
||||
onChange={() => toggleSelect(skill.directory)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.directory}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(skill.directory)}
|
||||
onChange={() => toggleSelect(skill.directory)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<AppToggleGroup
|
||||
apps={
|
||||
selectedApps[skill.directory] ?? {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
}
|
||||
}
|
||||
onToggle={(app, enabled) => {
|
||||
setSelectedApps((prev) => ({
|
||||
...prev,
|
||||
[skill.directory]: {
|
||||
...(prev[skill.directory] ?? {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
}),
|
||||
[app]: enabled,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
appIds={MCP_SKILLS_APP_IDS}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground/50 mt-1 truncate"
|
||||
title={skill.path}
|
||||
>
|
||||
{skill.path}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="text-xs text-muted-foreground/50 mt-1 truncate"
|
||||
title={skill.path}
|
||||
>
|
||||
{skill.path}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={selected.size === 0}>
|
||||
{t("skills.importSelected", { count: selected.size })}
|
||||
</Button>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={selected.size === 0}>
|
||||
{t("skills.importSelected", { count: selected.size })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,13 @@ export interface ProviderPreset {
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||
|
||||
// 供应商类型标识(用于特殊供应商检测)
|
||||
// - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证)
|
||||
providerType?: "github_copilot";
|
||||
|
||||
// 是否需要 OAuth 认证(而非 API Key)
|
||||
requiresOAuth?: boolean;
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
@@ -272,10 +279,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.7",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -298,10 +305,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.7",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -374,10 +381,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.7",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -394,10 +401,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.7",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -679,6 +686,25 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "novita",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot",
|
||||
websiteUrl: "https://github.com/features/copilot",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.githubcopilot.com",
|
||||
ANTHROPIC_MODEL: "claude-opus-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "claude-haiku-4.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4.6",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
apiFormat: "openai_chat",
|
||||
providerType: "github_copilot",
|
||||
requiresOAuth: true,
|
||||
icon: "github",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
websiteUrl: "https://build.nvidia.com",
|
||||
@@ -706,10 +732,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.xiaomimimo.com/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "mimo-v2-flash",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2-flash",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2-flash",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2-flash",
|
||||
ANTHROPIC_MODEL: "mimo-v2-pro",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2-pro",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2-pro",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2-pro",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Provider 类型常量
|
||||
export const PROVIDER_TYPES = {
|
||||
GITHUB_COPILOT: "github_copilot",
|
||||
} as const;
|
||||
|
||||
// 用量脚本模板类型常量
|
||||
export const TEMPLATE_TYPES = {
|
||||
CUSTOM: "custom",
|
||||
GENERAL: "general",
|
||||
NEW_API: "newapi",
|
||||
GITHUB_COPILOT: "github_copilot",
|
||||
} as const;
|
||||
|
||||
export type TemplateType =
|
||||
(typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
|
||||
@@ -340,8 +340,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMax-M2.7",
|
||||
name: "MiniMax M2.7",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -364,8 +364,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.5" },
|
||||
modelCatalog: { "minimax/MiniMax-M2.5": { alias: "MiniMax" } },
|
||||
model: { primary: "minimax/MiniMax-M2.7" },
|
||||
modelCatalog: { "minimax/MiniMax-M2.7": { alias: "MiniMax" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -378,8 +378,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMax-M2.7",
|
||||
name: "MiniMax M2.7",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -402,8 +402,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "minimax-en/MiniMax-M2.5" },
|
||||
modelCatalog: { "minimax-en/MiniMax-M2.5": { alias: "MiniMax" } },
|
||||
model: { primary: "minimax-en/MiniMax-M2.7" },
|
||||
modelCatalog: { "minimax-en/MiniMax-M2.7": { alias: "MiniMax" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -563,8 +563,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "mimo-v2-flash",
|
||||
name: "MiMo V2 Flash",
|
||||
id: "mimo-v2-pro",
|
||||
name: "MiMo V2 Pro",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -581,8 +581,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "xiaomimimo/mimo-v2-flash" },
|
||||
modelCatalog: { "xiaomimimo/mimo-v2-flash": { alias: "MiMo" } },
|
||||
model: { primary: "xiaomimimo/mimo-v2-pro" },
|
||||
modelCatalog: { "xiaomimimo/mimo-v2-pro": { alias: "MiMo" } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -599,13 +599,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -643,13 +643,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -687,13 +687,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -767,8 +767,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "Pro/MiniMaxAI/MiniMax-M2.7",
|
||||
name: "MiniMax M2.7",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -787,9 +787,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.5" },
|
||||
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.7" },
|
||||
modelCatalog: {
|
||||
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
|
||||
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.7": { alias: "MiniMax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -803,8 +803,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMaxAI/MiniMax-M2.7",
|
||||
name: "MiniMax M2.7",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -823,9 +823,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.5" },
|
||||
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.7" },
|
||||
modelCatalog: {
|
||||
"siliconflow-en/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
|
||||
"siliconflow-en/MiniMaxAI/MiniMax-M2.7": { alias: "MiniMax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -909,13 +909,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -954,13 +954,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -1000,13 +1000,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -1046,13 +1046,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -1092,13 +1092,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -1138,13 +1138,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -1184,13 +1184,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -1230,13 +1230,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
],
|
||||
@@ -1278,7 +1278,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
],
|
||||
@@ -1316,7 +1316,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
],
|
||||
@@ -1354,7 +1354,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
],
|
||||
@@ -1393,13 +1393,13 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
{
|
||||
id: "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
},
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user