mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c17ed867 | |||
| 1399cbbf06 | |||
| 602c5717b2 | |||
| 4685e5b597 | |||
| ca6a187745 | |||
| bfdac2a22a | |||
| 8d38f0ee4f | |||
| 159279bf44 | |||
| 2513687184 | |||
| bcc14bd07d | |||
| b31bf43e15 | |||
| 98230c3970 | |||
| 0200fe79ae | |||
| b30f3c27ad | |||
| d9c0e4c452 | |||
| fe525891d4 | |||
| 84998aa217 | |||
| f200feebe4 | |||
| 5017002938 | |||
| de49f6fbbe | |||
| e4fe2763cd | |||
| 3e1de466db | |||
| 1abe474e75 | |||
| 74d7e86b7a | |||
| b9297e42f5 | |||
| 7de2db9ee8 | |||
| 1b4c3cbcaa | |||
| 2b9b5b0bc3 | |||
| 2af38a63b5 | |||
| 3388cba1dc | |||
| 8b44d9f54d | |||
| 8cdc07c860 | |||
| 9a44245b73 | |||
| 1b2885c335 | |||
| 447a1da7b2 | |||
| bfe238f301 | |||
| 0c9a20ca68 | |||
| fee8577032 | |||
| bae369b0dc | |||
| 6e2baaefa6 | |||
| 0001438586 | |||
| 6e9e47ceb9 | |||
| 14a3799f41 | |||
| 2f5bf1cea9 | |||
| ba0b57f014 | |||
| 3553d05bf0 | |||
| 72f3610ac0 | |||
| 854266e3c4 | |||
| af8f907467 | |||
| 6a083cdd1c | |||
| c68476d0ea | |||
| 96a0a37331 | |||
| 8e2ffbc845 | |||
| 4f7ea76347 | |||
| 67e074c0a7 | |||
| b1c7fe5563 |
@@ -0,0 +1,2 @@
|
||||
custom:
|
||||
- https://github.com/farion1231/cc-switch/blob/main/README_ZH.md#%E2%9D%A4%EF%B8%8F%E8%B5%9E%E5%8A%A9%E5%95%86
|
||||
@@ -0,0 +1,97 @@
|
||||
name: "🐛 Bug Report / Bug 报告"
|
||||
description: |
|
||||
Report errors or unexpected behavior.
|
||||
报告错误或异常行为。
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have read the [FAQ](https://github.com/farion1231/cc-switch#faq) section in README.
|
||||
我已阅读 README 中的[常见问题](https://github.com/farion1231/cc-switch#常见问题)。
|
||||
required: true
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: CC Switch Version / 版本号
|
||||
description: |
|
||||
See the Settings page in the app.
|
||||
查看应用设置页面中的版本号。
|
||||
placeholder: "e.g. 3.11.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Operating System / 操作系统
|
||||
multiple: false
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Related App / 涉及应用
|
||||
description: |
|
||||
Which app is affected?
|
||||
涉及哪个应用?
|
||||
multiple: true
|
||||
options:
|
||||
- Claude Code
|
||||
- Codex
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce / 重现步骤
|
||||
description: |
|
||||
Please describe the steps to reproduce the bug. Screenshots and logs are helpful.
|
||||
请描述重现步骤。截图和日志会很有帮助。
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click on ...
|
||||
3. See error ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior / 期望行为
|
||||
description: |
|
||||
What did you expect to happen?
|
||||
你期望发生什么?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior / 实际行为
|
||||
description: |
|
||||
What actually happened?
|
||||
实际发生了什么?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: |
|
||||
Any other information, screenshots, or logs.
|
||||
其他信息、截图或日志。
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,12 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "Security Vulnerabilities / 安全漏洞"
|
||||
url: "https://github.com/farion1231/cc-switch/security/advisories/new"
|
||||
about: |
|
||||
Report security vulnerabilities through GitHub Security Advisories.
|
||||
请通过 GitHub 安全公告报告安全漏洞。
|
||||
- name: "Discussions / 讨论区"
|
||||
url: "https://github.com/farion1231/cc-switch/discussions"
|
||||
about: |
|
||||
General discussions and community help.
|
||||
一般讨论和社区帮助。
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "📖 Documentation Issue / 文档问题"
|
||||
description: |
|
||||
Report documentation errors, outdated content, or missing information.
|
||||
报告文档错误、过时内容或缺失信息。
|
||||
labels:
|
||||
- documentation
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Language / 语言
|
||||
description: |
|
||||
Which language version of the docs?
|
||||
涉及哪个语言版本的文档?
|
||||
multiple: true
|
||||
options:
|
||||
- English
|
||||
- 中文
|
||||
- 日本語
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Document Location / 文档位置
|
||||
description: |
|
||||
Which file or section is affected? Provide a link or file path.
|
||||
涉及哪个文件或章节?请提供链接或文件路径。
|
||||
placeholder: "e.g. README.md#faq or docs/user-manual/zh/..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Issue Type / 问题类型
|
||||
options:
|
||||
- "Typo or grammar / 拼写或语法错误"
|
||||
- "Outdated content / 内容过时"
|
||||
- "Missing information / 信息缺失"
|
||||
- "Translation issue / 翻译问题"
|
||||
- "Other / 其他"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description / 描述
|
||||
description: |
|
||||
Describe the documentation issue.
|
||||
描述文档问题。
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,75 @@
|
||||
name: "✨ Feature Request / 功能请求"
|
||||
description: |
|
||||
Propose a new feature or improvement.
|
||||
提出新功能或改进建议。
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have read the [FAQ](https://github.com/farion1231/cc-switch#faq) section in README.
|
||||
我已阅读 README 中的[常见问题](https://github.com/farion1231/cc-switch#常见问题)。
|
||||
required: true
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Related App / 涉及应用
|
||||
description: |
|
||||
Which app is this feature for?
|
||||
该功能涉及哪个应用?
|
||||
multiple: true
|
||||
options:
|
||||
- Claude Code
|
||||
- Codex
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- General / 通用
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem or Motivation / 问题或动机
|
||||
description: |
|
||||
Is this request related to a problem? Describe the scenario.
|
||||
这个请求是否与某个问题相关?请描述使用场景。
|
||||
placeholder: |
|
||||
I was trying to ... and found that ...
|
||||
我在尝试……的时候,发现……
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Solution / 建议方案
|
||||
description: |
|
||||
Describe the solution you'd like.
|
||||
描述你希望的解决方案。
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: |
|
||||
Any other context, mockups, or screenshots.
|
||||
其他背景信息、设计稿或截图。
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Contribution / 参与贡献
|
||||
options:
|
||||
- label: |
|
||||
I am interested in contributing to this feature.
|
||||
我有兴趣参与开发此功能。
|
||||
required: false
|
||||
@@ -0,0 +1,64 @@
|
||||
name: "❓ Question / 提问"
|
||||
description: |
|
||||
Ask a question about usage or configuration.
|
||||
询问使用或配置相关问题。
|
||||
labels:
|
||||
- question
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have read the [FAQ](https://github.com/farion1231/cc-switch#faq) section in README.
|
||||
我已阅读 README 中的[常见问题](https://github.com/farion1231/cc-switch#常见问题)。
|
||||
required: true
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Related App / 涉及应用
|
||||
description: |
|
||||
Which app is your question about?
|
||||
你的问题涉及哪个应用?
|
||||
multiple: true
|
||||
options:
|
||||
- Claude Code
|
||||
- Codex
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- General / 通用
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Your Question / 你的问题
|
||||
description: |
|
||||
Describe your question clearly.
|
||||
请清晰地描述你的问题。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What Have You Tried / 你已经尝试过什么
|
||||
description: |
|
||||
Describe what you've already tried or researched.
|
||||
描述你已经尝试过或查阅过的内容。
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment / 环境信息
|
||||
description: |
|
||||
OS, CC Switch version, and any relevant details.
|
||||
操作系统、CC Switch 版本及其他相关信息。
|
||||
placeholder: "e.g. macOS 15.4, CC Switch 3.11.1"
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,39 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
frontend-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "backend"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
cargo-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "actions"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
@@ -0,0 +1,25 @@
|
||||
## Summary / 概述
|
||||
|
||||
<!-- Briefly describe what this PR does and why. / 简要描述这个 PR 做了什么以及为什么。 -->
|
||||
|
||||
## Related Issue / 关联 Issue
|
||||
|
||||
<!-- Link the related issue. Use "Fixes #123" to auto-close it when merged. -->
|
||||
<!-- 关联相关 Issue。使用 "Fixes #123" 可在合并时自动关闭。 -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Screenshots / 截图
|
||||
|
||||
<!-- If applicable, add before/after screenshots. / 如有需要,请添加修改前后的截图。 -->
|
||||
|
||||
| Before / 修改前 | After / 修改后 |
|
||||
|-----------------|---------------|
|
||||
| | |
|
||||
|
||||
## Checklist / 检查清单
|
||||
|
||||
- [ ] `pnpm typecheck` passes / 通过 TypeScript 类型检查
|
||||
- [ ] `pnpm format:check` passes / 通过代码格式检查
|
||||
- [ ] `cargo clippy` passes (if Rust code changed) / 通过 Clippy 检查(如修改了 Rust 代码)
|
||||
- [ ] Updated i18n files if user-facing text changed / 如修改了用户可见文本,已更新国际化文件
|
||||
@@ -0,0 +1,99 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend Checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: TypeScript type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test:unit
|
||||
|
||||
backend:
|
||||
name: Backend Checks
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install Linux system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config libssl-dev \
|
||||
libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev
|
||||
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
|
||||
sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Cache Cargo registry and build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Create frontend dist placeholder
|
||||
run: mkdir -p dist
|
||||
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --check --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --manifest-path src-tauri/Cargo.toml
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -490,7 +490,7 @@ jobs:
|
||||
ls -la release-assets/*.sig || echo "No signatures found"
|
||||
|
||||
- name: Upload release artifacts to workflow
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-assets-${{ runner.os }}-${{ matrix.arch || runner.arch }}
|
||||
path: release-assets/*
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download built release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-assets-*
|
||||
path: release-assets
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Close Stale Issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 00:00 UTC
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
# --- Timing ---
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
|
||||
# --- Stale label ---
|
||||
stale-issue-label: 'stale'
|
||||
|
||||
# --- Messages (bilingual) ---
|
||||
stale-issue-message: |
|
||||
此 issue 已超过 60 天没有活动,已被标记为 `stale`。
|
||||
如果此问题仍然存在,请回复以保持 issue 打开状态,否则将在 14 天后自动关闭。
|
||||
|
||||
This issue has been automatically marked as `stale` because it has not had activity for 60 days.
|
||||
Please reply to keep it open, otherwise it will be automatically closed in 14 days.
|
||||
close-issue-message: |
|
||||
此 issue 因长时间无活动已被自动关闭。如果问题仍然存在,欢迎重新打开。
|
||||
|
||||
This issue has been automatically closed due to inactivity. If the problem persists, feel free to reopen it.
|
||||
|
||||
# --- Exemptions ---
|
||||
# Issues with these labels will NEVER be marked stale
|
||||
exempt-issue-labels: 'security,performance'
|
||||
# Issues with 3+ reactions are likely popular requests, exempt them
|
||||
exempt-issue-created-after: '2020-01-01'
|
||||
|
||||
# --- Processing limits ---
|
||||
# Process up to 100 issues per run to avoid API rate limits
|
||||
operations-per-run: 100
|
||||
|
||||
# --- Only issues, not PRs ---
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
@@ -0,0 +1,175 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
> [中文版本](#贡献者公约行为准则)
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **farion1231@gmail.com**. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
|
||||
---
|
||||
|
||||
# 贡献者公约行为准则
|
||||
|
||||
> [English Version](#contributor-covenant-code-of-conduct)
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
身为社区成员、贡献者和领袖,我们承诺使社区参与者不受骚扰,无论其年龄、体型、可见或不可见的缺陷、族裔、性征、性别认同和表达、经验水平、教育程度、社会与经济地位、国籍、相貌、种族、种姓、肤色、宗教信仰、性倾向或性取向如何。
|
||||
|
||||
我们承诺以有助于建立开放、友善、多样化、包容、健康社区的方式行事和互动。
|
||||
|
||||
## 我们的准则
|
||||
|
||||
有助于为我们的社区创造积极环境的行为例子包括但不限于:
|
||||
|
||||
- 表现出对他人的同情和善意
|
||||
- 尊重不同的主张、观点和感受
|
||||
- 提出和大方接受建设性意见
|
||||
- 承担责任并向受我们错误影响的人道歉
|
||||
- 注重社区共同诉求,而非个人得失
|
||||
|
||||
不当行为例子包括:
|
||||
|
||||
- 使用情色化的语言或图像,及性引诱或挑逗
|
||||
- 嘲弄、侮辱或诋毁性评论,以及人身或政治攻击
|
||||
- 公开或私下的骚扰行为
|
||||
- 未经他人明确许可,公布他人的私人信息,如物理或电子邮件地址
|
||||
- 其他有理由认定为违反职业操守的不当行为
|
||||
|
||||
## 责任和权力
|
||||
|
||||
社区领袖有责任解释和落实我们所认可的行为准则,并妥善公正地对他们认为不当、威胁、冒犯或有害的任何行为采取纠正措施。
|
||||
|
||||
社区领袖有权力和责任删除、编辑或拒绝与本行为准则不相符的评论(comment)、提交(able)、代码、维基(wiki)编辑、议题(able)或其他贡献,并在适当时告知采取措施的理由。
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于所有社区场合,也适用于在公共场所代表社区时的个人。
|
||||
|
||||
代表社区的情形包括使用官方电子邮件地址、通过官方社交媒体帐户发帖或在线上或线下活动中担任指定代表。
|
||||
|
||||
## 监督
|
||||
|
||||
辱骂、骚扰或其他不可接受的行为可通过 **farion1231@gmail.com** 向负责监督的社区领袖报告。所有投诉都将得到及时和公平的审查和调查。
|
||||
|
||||
所有社区领袖都有义务尊重任何事件报告者的隐私和安全。
|
||||
|
||||
## 处理方针
|
||||
|
||||
社区领袖将遵循下列社区处理方针来明确他们所认定违反本行为准则的行为的处理方式:
|
||||
|
||||
### 1. 纠正
|
||||
|
||||
**社区影响**:使用不恰当的语言或其他在社区中被认定为不符合职业道德或不受欢迎的行为。
|
||||
|
||||
**处理意见**:由社区领袖发出非公开的书面警告,明确说明违规行为的性质,并解释举止如何不妥。或将要求公开道歉。
|
||||
|
||||
### 2. 警告
|
||||
|
||||
**社区影响**:单个或一系列违规行为。
|
||||
|
||||
**处理意见**:警告并对连续性行为进行处理。在指定时间内,不得与相关人员互动,包括主动与行为准则执行者互动。这包括避免在社区场所和外部渠道中的互动。违反这些条款可能会导致临时或永久封禁。
|
||||
|
||||
### 3. 临时封禁
|
||||
|
||||
**社区影响**:严重违反社区准则,包括持续的不当行为。
|
||||
|
||||
**处理意见**:在指定时间内,暂时禁止与社区进行任何形式的互动或公开交流。在此期间,不得与相关人员进行公开或私下互动,包括主动与行为准则执行者互动。违反这些条款可能会导致永久封禁。
|
||||
|
||||
### 4. 永久封禁
|
||||
|
||||
**社区影响**:行为模式表现出违反社区准则,包括持续的不当行为、骚扰个人或攻击或贬低某个类别的个体。
|
||||
|
||||
**处理意见**:永久禁止在社区内进行任何形式的公开互动。
|
||||
|
||||
## 参见
|
||||
|
||||
本行为准则改编自 [Contributor Covenant][homepage] 2.1 版,参见 [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]。
|
||||
|
||||
社区处理方针灵感来源于 [Mozilla 的行为准则执行阶梯][Mozilla CoC]。
|
||||
|
||||
有关本行为准则的常见问题的答案,参见 [https://www.contributor-covenant.org/faq][FAQ]。其他语言翻译参见 [https://www.contributor-covenant.org/translations][translations]。
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
# Contributing to CC Switch
|
||||
|
||||
> [中文版本](#贡献指南)
|
||||
|
||||
Thank you for your interest in contributing to CC Switch! Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) before participating.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
There are many ways to contribute:
|
||||
|
||||
- **Report bugs** — Found something broken? [Open a bug report](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml).
|
||||
- **Suggest features** — Have an idea? [Submit a feature request](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml).
|
||||
- **Improve docs** — Spot a typo or missing info? [Report a doc issue](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml).
|
||||
- **Contribute code** — Fix bugs or implement features via pull requests.
|
||||
- **Translate** — Help us improve translations for English, Chinese, and Japanese.
|
||||
|
||||
> **Security vulnerabilities**: Please do NOT use public issues. See our [Security Policy](./SECURITY.md) instead.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and pnpm 8+
|
||||
- Rust 1.85+ and Cargo
|
||||
- [Tauri 2.0 prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development server with hot reload
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Start dev server (hot reload) |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm test:unit` | Run unit tests |
|
||||
| `pnpm lint` | ESLint check |
|
||||
| `pnpm format` | Format code (Prettier) |
|
||||
| `pnpm format:check` | Check code formatting |
|
||||
|
||||
For Rust backend:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo fmt # Format Rust code
|
||||
cargo clippy # Run linter
|
||||
cargo test # Run tests
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Frontend**: Prettier for formatting, ESLint for linting, strict TypeScript (`pnpm typecheck`)
|
||||
- **Backend**: `cargo fmt` for formatting, `cargo clippy` for linting
|
||||
- **Tauri 2.0**: Command names must use camelCase
|
||||
|
||||
Run all checks before submitting:
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm format:check && pnpm test:unit
|
||||
cd src-tauri && cargo fmt --check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
1. **Open an issue first** for new features — PRs for features that are not a good fit may be closed.
|
||||
2. **Fork and branch** — Create a feature branch from `main` (e.g., `feat/my-feature` or `fix/issue-123`).
|
||||
3. **Keep PRs focused** — One feature or fix per PR. Avoid unrelated changes.
|
||||
4. **Follow the PR template** — Fill in the summary, related issue, and checklist.
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm format:check` passes
|
||||
- [ ] `cargo clippy` passes (if Rust code changed)
|
||||
- [ ] Updated i18n files if user-facing text changed
|
||||
|
||||
### Commit Convention
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(provider): add support for new provider
|
||||
fix(tray): resolve menu not updating after switch
|
||||
docs(readme): update installation instructions
|
||||
ci: add format check workflow
|
||||
chore(deps): update dependencies
|
||||
```
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
We welcome AI-assisted contributions, but **the responsibility stays with you**. AI tools lower the cost of writing code — they do not lower the cost of reviewing it. Maintainers are not obligated to clean up AI-generated output.
|
||||
|
||||
By submitting a PR, you agree to the following:
|
||||
|
||||
1. **You have read and understood your code.** You must be able to explain any line in your PR. If you cannot, it is not ready for review.
|
||||
2. **You have tested it yourself.** Every change must be verified locally — not just "it looks right." Do not submit code for platforms or features you cannot test.
|
||||
3. **PRs must be small and focused.** One issue, one PR. Large, sprawling, multi-topic PRs will be closed.
|
||||
4. **Open an issue first.** Drive-by PRs with no prior discussion — especially AI-generated ones — may be closed without review.
|
||||
5. **Maintainers may close without explanation.** PRs that appear to be unreviewed AI output — hallucinated fixes, unnecessary refactors, bulk changes with no context — may be closed at the maintainer's discretion.
|
||||
|
||||
**In short**: AI is a tool, not a substitute for understanding. Use it to help you contribute better, not to shift work onto maintainers.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
CC Switch supports three languages. When modifying user-facing text:
|
||||
|
||||
1. Update **all three** locale files:
|
||||
- `src/locales/en/translation.json`
|
||||
- `src/locales/zh/translation.json`
|
||||
- `src/locales/ja/translation.json`
|
||||
2. Use the `t()` function from i18next for all UI text.
|
||||
3. Never hardcode user-facing strings.
|
||||
|
||||
## Questions?
|
||||
|
||||
- [Open a question](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||
|
||||
---
|
||||
|
||||
# 贡献指南
|
||||
|
||||
> [English Version](#contributing-to-cc-switch)
|
||||
|
||||
感谢你对 CC Switch 的贡献兴趣!参与之前请阅读我们的[行为准则](./CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 如何贡献
|
||||
|
||||
你可以通过多种方式参与贡献:
|
||||
|
||||
- **报告 Bug** — 发现问题?[提交 Bug 报告](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml)。
|
||||
- **建议功能** — 有想法?[提交功能请求](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml)。
|
||||
- **改进文档** — 发现错误或缺失?[报告文档问题](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml)。
|
||||
- **贡献代码** — 通过 Pull Request 修复 Bug 或实现新功能。
|
||||
- **翻译** — 帮助改进英文、中文和日文的翻译。
|
||||
|
||||
> **安全漏洞**:请不要使用公开 Issue 报告。请参阅我们的[安全策略](./SECURITY.md)。
|
||||
|
||||
## 开发环境搭建
|
||||
|
||||
### 前提条件
|
||||
|
||||
- Node.js 18+ 和 pnpm 8+
|
||||
- Rust 1.85+ 和 Cargo
|
||||
- [Tauri 2.0 开发环境](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
### 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器(热重载)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pnpm dev` | 启动开发服务器(热重载) |
|
||||
| `pnpm build` | 构建生产版本 |
|
||||
| `pnpm typecheck` | TypeScript 类型检查 |
|
||||
| `pnpm test:unit` | 运行单元测试 |
|
||||
| `pnpm lint` | ESLint 检查 |
|
||||
| `pnpm format` | 格式化代码(Prettier) |
|
||||
| `pnpm format:check` | 检查代码格式 |
|
||||
|
||||
Rust 后端命令:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo fmt # 格式化 Rust 代码
|
||||
cargo clippy # 运行 Clippy 检查
|
||||
cargo test # 运行测试
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **前端**:使用 Prettier 格式化、ESLint 检查、严格 TypeScript(`pnpm typecheck`)
|
||||
- **后端**:使用 `cargo fmt` 格式化、`cargo clippy` 检查
|
||||
- **Tauri 2.0**:命令名必须使用 camelCase
|
||||
|
||||
提交前运行所有检查:
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm format:check && pnpm test:unit
|
||||
cd src-tauri && cargo fmt --check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
## Pull Request 指南
|
||||
|
||||
1. **先开 Issue 讨论** — 新功能请先开 Issue,不适合项目方向的 PR 可能会被关闭。
|
||||
2. **Fork 并创建分支** — 从 `main` 创建功能分支(如 `feat/my-feature` 或 `fix/issue-123`)。
|
||||
3. **保持 PR 专注** — 每个 PR 只做一件事,避免无关改动。
|
||||
4. **遵循 PR 模板** — 填写概述、关联 Issue 和检查清单。
|
||||
|
||||
### PR 检查清单
|
||||
|
||||
- [ ] `pnpm typecheck` 通过
|
||||
- [ ] `pnpm format:check` 通过
|
||||
- [ ] `cargo clippy` 通过(如修改了 Rust 代码)
|
||||
- [ ] 如修改了用户可见文本,已更新国际化文件
|
||||
|
||||
### 提交信息规范
|
||||
|
||||
我们使用 [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(provider): add support for new provider
|
||||
fix(tray): resolve menu not updating after switch
|
||||
docs(readme): update installation instructions
|
||||
ci: add format check workflow
|
||||
chore(deps): update dependencies
|
||||
```
|
||||
|
||||
## AI 辅助贡献
|
||||
|
||||
我们欢迎 AI 辅助的贡献,但**责任始终在你身上**。AI 工具降低了写代码的成本,但并没有降低 review 的成本。维护者没有义务替你清理 AI 的产出。
|
||||
|
||||
提交 PR 即表示你同意以下规则:
|
||||
|
||||
1. **你已阅读并理解了你的代码。** 你必须能解释 PR 中的每一行。如果做不到,说明还没准备好提交 review。
|
||||
2. **你已亲自测试过。** 每个改动都必须在本地验证——而不是"看起来对"。不要提交你自己无法测试的平台或功能的代码。
|
||||
3. **PR 必须小而聚焦。** 一个 Issue 对应一个 PR。大而散、跨多个主题的 PR 会被直接关闭。
|
||||
4. **先开 Issue 讨论。** 没有事先讨论的"路过式 PR"——尤其是 AI 生成的——可能会被直接关闭。
|
||||
5. **维护者可以直接关闭。** 看起来是未经审阅的 AI 产出的 PR——虚构的修复、不必要的重构、缺乏上下文的批量改动——维护者可自行决定关闭。
|
||||
|
||||
**一句话总结**:AI 是工具,不是理解力的替代品。用它来帮助你更好地贡献,而不是把工作转移给维护者。
|
||||
|
||||
## 国际化(i18n)
|
||||
|
||||
CC Switch 支持三种语言。修改用户可见文本时:
|
||||
|
||||
1. **同时更新三个**语言文件:
|
||||
- `src/locales/en/translation.json`
|
||||
- `src/locales/zh/translation.json`
|
||||
- `src/locales/ja/translation.json`
|
||||
2. 所有 UI 文本使用 i18next 的 `t()` 函数。
|
||||
3. 不要硬编码用户可见的字符串。
|
||||
|
||||
## 有疑问?
|
||||
|
||||
- [提问](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- [GitHub 讨论区](https://github.com/farion1231/cc-switch/discussions)
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
### 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)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -17,6 +17,8 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
> [Want to appear here?](mailto:farion1231@gmail.com)
|
||||
|
||||
<details open>
|
||||
<summary>Click to collapse</summary>
|
||||
|
||||
@@ -100,6 +102,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -287,14 +294,8 @@ Download the latest Linux build from the [Releases](../../releases) page:
|
||||
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
|
||||
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
|
||||
|
||||
Flatpak install & run:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
> **Flatpak**: Not included in official releases. You can build it yourself from the `.deb` — see [`flatpak/README.md`](flatpak/README.md) for instructions.
|
||||
|
||||
<details>
|
||||
<summary><strong>Architecture Overview</strong></summary>
|
||||
@@ -333,7 +334,7 @@ flatpak run com.ccswitch.desktop
|
||||
- **ProviderService**: Provider CRUD, switching, backfill, sorting
|
||||
- **McpService**: MCP server management, import/export, live file sync
|
||||
- **ProxyService**: Local proxy mode with hot-switching and format conversion
|
||||
- **SessionManager**: Claude Code conversation history browsing
|
||||
- **SessionManager**: Conversation history browsing across all supported apps
|
||||
- **ConfigService**: Config import/export, backup rotation
|
||||
- **SpeedtestService**: API endpoint latency measurement
|
||||
|
||||
|
||||
+13
-12
@@ -4,10 +4,10 @@
|
||||
|
||||
### 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)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
## ❤️スポンサー
|
||||
|
||||
> [ここに掲載しませんか?](mailto:farion1231@gmail.com)
|
||||
|
||||
<details open>
|
||||
<summary>クリックで折りたたむ</summary>
|
||||
|
||||
@@ -100,6 +102,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -184,9 +191,9 @@ CC Switch には「共有設定スニペット」機能があり、APIキーや
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS で「開発元を確認できません」と表示されます。どうすればよいですか?</strong></summary>
|
||||
<summary><strong>macOS のインストールについて</strong></summary>
|
||||
|
||||
開発者が Apple Developer アカウントをまだ取得していないためです(登録手続き中)。警告を閉じてから、**システム設定 → プライバシーとセキュリティ → このまま開く**をクリックしてください。以降は通常通り起動できます。
|
||||
CC Switch の macOS 版は Apple によるコード署名と公証が完了しています。直接ダウンロードしてインストールできます — 追加の手順は不要です。`.dmg` インストーラの使用を推奨します。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -287,14 +294,8 @@ paru -S cc-switch-bin
|
||||
- `CC-Switch-v{version}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
|
||||
- `CC-Switch-v{version}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak のインストールと起動:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
> **Flatpak**:公式リリースには含まれていません。`.deb` から自分でビルドできます — 手順は [`flatpak/README.md`](flatpak/README.md) を参照してください。
|
||||
|
||||
<details>
|
||||
<summary><strong>アーキテクチャ概要</strong></summary>
|
||||
@@ -333,7 +334,7 @@ flatpak run com.ccswitch.desktop
|
||||
- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート
|
||||
- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期
|
||||
- **ProxyService**: ローカル Proxy モードのホットスイッチとフォーマット変換
|
||||
- **SessionManager**: Claude Code の会話履歴閲覧
|
||||
- **SessionManager**: 対応する全アプリの会話履歴閲覧
|
||||
- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション
|
||||
- **SpeedtestService**: API エンドポイントの遅延計測
|
||||
|
||||
|
||||
+11
-10
@@ -4,10 +4,10 @@
|
||||
|
||||
### 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)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
> [想出现在这里?](mailto:farion1231@gmail.com)
|
||||
|
||||
<details open>
|
||||
<summary>点击折叠</summary>
|
||||
|
||||
@@ -101,6 +103,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -290,14 +297,8 @@ paru -S cc-switch-bin
|
||||
- `CC-Switch-v{版本号}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{版本号}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
|
||||
- `CC-Switch-v{版本号}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak 安装与运行:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
> **Flatpak**:官方 Release 不包含 Flatpak 包。如需使用,可从 `.deb` 自行构建 — 参见 [`flatpak/README.md`](flatpak/README.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>架构总览</strong></summary>
|
||||
@@ -336,7 +337,7 @@ flatpak run com.ccswitch.desktop
|
||||
- **ProviderService**:供应商增删改查、切换、回填、排序
|
||||
- **McpService**:MCP 服务器管理、导入导出、live 文件同步
|
||||
- **ProxyService**:本地 Proxy 模式,支持热切换和格式转换
|
||||
- **SessionManager**:Claude Code 对话历史浏览
|
||||
- **SessionManager**:全应用会话历史浏览
|
||||
- **ConfigService**:配置导入导出、备份轮换
|
||||
- **SpeedtestService**:API 端点延迟测量
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Security Policy / 安全策略
|
||||
|
||||
## Supported Versions / 支持的版本
|
||||
|
||||
Only the latest release of CC Switch receives security updates.
|
||||
|
||||
仅最新版本的 CC Switch 会收到安全更新。
|
||||
|
||||
| Version / 版本 | Supported / 是否支持 |
|
||||
|----------------|---------------------|
|
||||
| Latest 3.x | ✅ Yes / 是 |
|
||||
| < 3.0 | ❌ No / 否 |
|
||||
|
||||
## Reporting a Vulnerability / 报告漏洞
|
||||
|
||||
**Please do NOT report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
**请不要通过公开的 GitHub Issue 报告安全漏洞。**
|
||||
|
||||
Instead, please report them through [GitHub Security Advisories](https://github.com/farion1231/cc-switch/security/advisories/new).
|
||||
|
||||
请通过 [GitHub 安全公告](https://github.com/farion1231/cc-switch/security/advisories/new) 进行报告。
|
||||
|
||||
When reporting, please include:
|
||||
|
||||
报告时请包含以下信息:
|
||||
|
||||
- A description of the vulnerability / 漏洞描述
|
||||
- Steps to reproduce / 复现步骤
|
||||
- Potential impact / 潜在影响
|
||||
- Affected versions / 受影响版本
|
||||
|
||||
## Response Timeline / 响应时间
|
||||
|
||||
- **Acknowledgment / 确认**: within 48 hours / 48 小时内
|
||||
- **Initial assessment / 初步评估**: within 7 days / 7 天内
|
||||
- **Fix for critical issues / 关键问题修复**: within 14 days / 14 天内
|
||||
|
||||
## Disclosure Policy / 披露政策
|
||||
|
||||
We follow a coordinated disclosure process:
|
||||
|
||||
我们遵循协调披露流程:
|
||||
|
||||
1. The reporter submits the vulnerability privately. / 报告者私下提交漏洞。
|
||||
2. We confirm and work on a fix. / 我们确认并修复漏洞。
|
||||
3. A patch release is published. / 发布修复版本。
|
||||
4. The vulnerability is publicly disclosed. / 公开披露漏洞详情。
|
||||
|
||||
Reporters will be credited in the release notes unless they prefer to remain anonymous.
|
||||
|
||||
除非报告者希望匿名,否则将在发布说明中致谢。
|
||||
|
||||
## Security Updates / 安全更新
|
||||
|
||||
Security fixes are released as patch versions and announced via [GitHub Releases](https://github.com/farion1231/cc-switch/releases). We recommend always updating to the latest version.
|
||||
|
||||
安全修复通过补丁版本发布,并通过 [GitHub Releases](https://github.com/farion1231/cc-switch/releases) 通知。建议始终更新到最新版本。
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Support / 获取帮助
|
||||
|
||||
> [中文版本](#获取帮助)
|
||||
|
||||
## How to Get Help
|
||||
|
||||
CC Switch is an open-source project maintained by volunteers. We're happy to help, but please use the right channel so we can respond efficiently.
|
||||
|
||||
### Before Asking
|
||||
|
||||
1. **Read the [FAQ](https://github.com/farion1231/cc-switch#faq)** — most common questions are answered there.
|
||||
2. **Search [existing issues](https://github.com/farion1231/cc-switch/issues)** (including closed ones) — someone may have had the same question.
|
||||
|
||||
### Asking a Question
|
||||
|
||||
- **Usage or configuration questions**: [Open a Question issue](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- **General discussion**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||
|
||||
### Reporting Problems
|
||||
|
||||
- **Bug reports**: [Open a Bug Report](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml)
|
||||
- **Documentation issues**: [Open a Doc Issue](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml)
|
||||
- **Security vulnerabilities**: Please do NOT use public issues. See our [Security Policy](./SECURITY.md).
|
||||
|
||||
### Feature Requests
|
||||
|
||||
- [Submit a Feature Request](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml)
|
||||
- Please open an issue for discussion before submitting a PR for new features.
|
||||
|
||||
---
|
||||
|
||||
# 获取帮助
|
||||
|
||||
> [English Version](#support--获取帮助)
|
||||
|
||||
## 如何获取帮助
|
||||
|
||||
CC Switch 是一个由志愿者维护的开源项目。我们很乐意提供帮助,但请使用合适的渠道,以便我们高效响应。
|
||||
|
||||
### 提问之前
|
||||
|
||||
1. **阅读 [常见问题](https://github.com/farion1231/cc-switch#常见问题)** — 大多数常见问题都已在其中解答。
|
||||
2. **搜索 [已有的 Issue](https://github.com/farion1231/cc-switch/issues)**(包括已关闭的) — 可能已经有人问过相同的问题。
|
||||
|
||||
### 提问
|
||||
|
||||
- **使用或配置问题**:[提交问题 Issue](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- **一般讨论**:[GitHub 讨论区](https://github.com/farion1231/cc-switch/discussions)
|
||||
|
||||
### 报告问题
|
||||
|
||||
- **Bug 报告**:[提交 Bug 报告](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml)
|
||||
- **文档问题**:[提交文档问题](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml)
|
||||
- **安全漏洞**:请不要使用公开 Issue。请参阅我们的[安全策略](./SECURITY.md)。
|
||||
|
||||
### 功能请求
|
||||
|
||||
- [提交功能请求](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml)
|
||||
- 提交新功能的 PR 之前,请先开 Issue 讨论。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
@@ -12,8 +12,8 @@
|
||||
|
||||
## Version / 版本 / バージョン
|
||||
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Documentation version: v3.12.3
|
||||
- Last updated: 2026-04-02
|
||||
- Compatible with CC Switch v3.12.0+
|
||||
|
||||
## Links
|
||||
|
||||
@@ -100,10 +100,13 @@ CC Switch displays an icon in the system tray, providing quick access to operati
|
||||
| Menu Item | Function |
|
||||
|-----------|----------|
|
||||
| Open Main Window | Show and focus the main window |
|
||||
| App Groups | Providers grouped by Claude/Codex/Gemini/OpenCode/OpenClaw |
|
||||
| Provider List | Click to switch; currently active one shows a checkmark |
|
||||
| App Submenus | Collapsible submenus grouped by Claude/Codex/Gemini (e.g., "Claude · PackyCode") |
|
||||
| Provider List | Inside each submenu, click to switch; currently active shows a checkmark |
|
||||
| Lightweight Mode | Toggle checkbox to enter/exit tray-only mode |
|
||||
| Quit | Fully exit the application |
|
||||
|
||||
> **Note**: Each app submenu title shows the current provider name (e.g., "Claude · PackyCode"). Apps with no configured providers show a disabled "(no providers)" entry. App visibility is controlled by the App Visibility setting.
|
||||
|
||||
### Multi-language Support
|
||||
|
||||
The tray menu supports three languages, automatically switching based on settings:
|
||||
@@ -114,6 +117,17 @@ The tray menu supports three languages, automatically switching based on setting
|
||||
| English | Open main window | Quit |
|
||||
| Japanese | Open main window | Quit |
|
||||
|
||||
### Lightweight Mode
|
||||
|
||||
The tray menu includes a **Lightweight Mode** toggle (checkbox). When enabled:
|
||||
|
||||
- The main window is closed to free up resources
|
||||
- The app continues running in the system tray only
|
||||
- You can still switch providers via the tray submenus
|
||||
- On macOS, the Dock icon is also hidden
|
||||
|
||||
To exit Lightweight Mode, uncheck the toggle or click "Open main window" — the main window will be rebuilt and shown.
|
||||
|
||||
### Use Cases
|
||||
|
||||
Switching providers via the tray menu doesn't require opening the main window, suitable for:
|
||||
@@ -121,6 +135,7 @@ Switching providers via the tray menu doesn't require opening the main window, s
|
||||
- Frequently switching providers
|
||||
- Quick operations when the main window is minimized
|
||||
- Managing configurations while running in the background
|
||||
- Running in Lightweight Mode for minimal resource usage
|
||||
|
||||
## Settings Page
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ After switching providers, each CLI tool activates differently:
|
||||
| Claude Code | Instant effect (supports hot reload) |
|
||||
| Codex | Requires closing and reopening the terminal |
|
||||
| Gemini | Instant effect (re-reads config on each request) |
|
||||
| OpenCode | Requires closing and reopening the terminal |
|
||||
| OpenClaw | Requires closing and reopening the terminal |
|
||||
|
||||
### Claude Code First Launch Prompt
|
||||
|
||||
@@ -64,6 +66,14 @@ codex
|
||||
# Gemini - enter a test question after launching
|
||||
gemini
|
||||
> Hello, please briefly introduce yourself
|
||||
|
||||
# OpenCode - enter a test question after launching
|
||||
opencode
|
||||
> Hello, please briefly introduce yourself
|
||||
|
||||
# OpenClaw - enter a test question after launching
|
||||
openclaw
|
||||
> Hello, please briefly introduce yourself
|
||||
```
|
||||
|
||||
If the AI responds normally, the configuration is successful.
|
||||
|
||||
@@ -167,37 +167,74 @@ Import/export configuration backups. See the "Data Management" section above for
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
Manage automatic backups:
|
||||
The Backup Management panel provides full control over database backups.
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Backup Interval | Time interval for automatic backups (hours) |
|
||||
| Retention Count | Number of backups to retain |
|
||||
#### Auto-Backup Settings
|
||||
|
||||
Supports viewing the backup list and restoring from backups.
|
||||
| Setting | Options | Default |
|
||||
|---------|---------|---------|
|
||||
| Backup Interval | Disabled, 6h, 12h, 24h, 48h, 7d | 24 hours |
|
||||
| Retention Count | 3, 5, 10, 15, 20, 30, 50 | 10 backups |
|
||||
|
||||
When an interval is set, CC Switch automatically backs up the database on schedule. Older backups beyond the retention count are automatically removed.
|
||||
|
||||
#### Backup List
|
||||
|
||||
The panel displays all existing backups with:
|
||||
- **Display name** (auto-generated from timestamp, e.g., `db_backup_20260315_143000`)
|
||||
- **Creation time**
|
||||
- **File size** (e.g., "1.5 MB")
|
||||
|
||||
#### Backup Operations
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| **Backup Now** | Create a backup immediately |
|
||||
| **Restore** | Restore the database from a selected backup. A safety backup of the current database is created automatically before restoring |
|
||||
| **Rename** | Change the backup's display name |
|
||||
| **Delete** | Permanently remove a backup (with confirmation) |
|
||||
|
||||
> **Important**: Restoring a backup overwrites the current database. A safety backup is always created before the restore operation, so you can recover if needed.
|
||||
|
||||
### Cloud Sync (WebDAV)
|
||||
|
||||
Sync configurations across multiple devices via the WebDAV protocol.
|
||||
Sync configurations across multiple devices via the WebDAV protocol. Uses **v2 protocol** with dual-layer versioning for improved reliability.
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Service Preset | Jianguoyun / Nextcloud / Synology / Custom |
|
||||
| Server URL | WebDAV server URL |
|
||||
| Username | Login username |
|
||||
| Password | Login password (app-specific password) |
|
||||
| Password | Login password (app-specific password; saved credentials are preserved if left unchanged) |
|
||||
| Remote Directory | Remote storage path (default: `cc-switch-sync`) |
|
||||
| Profile Name | Device profile name (default: `default`) |
|
||||
| Auto Sync | Automatically upload changes when enabled |
|
||||
| Auto Sync | Enable automatic synchronization on a configurable interval |
|
||||
|
||||
Operations:
|
||||
#### Operations
|
||||
|
||||
- **Test Connection**: Verify WebDAV configuration is correct
|
||||
- **Save**: Save configuration and auto-test
|
||||
- **Upload**: Upload local data to the remote server
|
||||
- **Download**: Download data from the remote server to local
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| **Test Connection** | Verify WebDAV URL, username, and password are valid |
|
||||
| **Upload** | Upload local database to remote. Shows progress spinner |
|
||||
| **Download** | Download remote database. Shows remote snapshot info (protocol version, DB version, timestamp, size) before confirming. A safety backup of the local database is created automatically before overwriting |
|
||||
|
||||
> **Note**: Upload will overwrite remote data, and download will overwrite local data. Please confirm before proceeding.
|
||||
#### Auto-Sync
|
||||
|
||||
When **Auto Sync** is enabled:
|
||||
- A confirmation dialog is shown on first activation
|
||||
- CC Switch automatically syncs the database to WebDAV at the configured interval
|
||||
- Sync status is displayed in the panel
|
||||
|
||||
#### Remote Snapshot Info
|
||||
|
||||
Before downloading, CC Switch displays details about the remote snapshot:
|
||||
- Protocol version (v2)
|
||||
- Database compatibility version
|
||||
- Timestamp of the remote backup
|
||||
- File size
|
||||
- Compatibility status (warning if incompatible)
|
||||
|
||||
> **Note**: Upload overwrites remote data, and download overwrites local data. A safety backup is always created before downloading.
|
||||
|
||||
### Log Configuration
|
||||
|
||||
|
||||
@@ -152,6 +152,22 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| AWS Bedrock | AWS Bedrock service |
|
||||
| OpenAI Compatible | OpenAI-compatible interface |
|
||||
|
||||
## Auto-Fetch Models
|
||||
|
||||
When adding or editing a provider, you can auto-fetch available models from the provider's endpoint:
|
||||
|
||||
1. Ensure the **API Key** and **Endpoint URL** are filled in
|
||||
2. Click the **Fetch Models** button (download icon) next to the model input field
|
||||
3. CC Switch calls the provider's `/v1/models` endpoint to retrieve the model list
|
||||
4. Select a model from the dropdown, grouped by vendor
|
||||
|
||||
This feature works for any provider that supports the OpenAI-compatible `/v1/models` API. It is available for Claude, Codex, Gemini, OpenCode, and OpenClaw providers.
|
||||
|
||||
**Common errors:**
|
||||
- **Authentication failed (401/403)**: Check your API Key
|
||||
- **Endpoint not supported (404/405)**: The provider does not expose a `/v1/models` endpoint
|
||||
- **Timeout**: The endpoint is slow to respond; try again later
|
||||
|
||||
## Custom Configuration
|
||||
|
||||
After selecting the "Custom" preset, you need to manually edit the JSON configuration.
|
||||
@@ -316,6 +332,45 @@ Batch import from SQL backup files:
|
||||
|
||||
## Advanced Options
|
||||
|
||||
### API Format (Claude Only)
|
||||
|
||||
When adding a Claude provider that uses a third-party API, you may need to select the correct **API Format** in the Advanced Options section:
|
||||
|
||||
| Format | Description | When to Use |
|
||||
|--------|-------------|-------------|
|
||||
| **Anthropic Messages** | Native Anthropic API format (default) | Direct Anthropic API or compatible proxies |
|
||||
| **OpenAI Chat Completions** | OpenAI Chat API format, auto-converted by proxy | Provider only supports OpenAI Chat format |
|
||||
| **OpenAI Responses API** | OpenAI Responses API format, auto-converted by proxy | Provider only supports OpenAI Responses format |
|
||||
|
||||
> **Note**: API format conversion is handled by the proxy service. When using non-Anthropic formats, the proxy must be running with takeover enabled for correct request/response conversion. See [4.1 Proxy Service](../4-proxy/4.1-service.md) for details.
|
||||
|
||||
The Advanced Options section auto-expands when a non-default API format is configured.
|
||||
|
||||
### Claude Common Config Toggles
|
||||
|
||||
When editing Claude providers, a set of **quick toggles** is available above the JSON editor:
|
||||
|
||||
| Toggle | Effect | Config Change |
|
||||
|--------|--------|---------------|
|
||||
| **Hide Attribution** | Clears commit/PR attribution metadata | Sets `attribution: {commit: "", pr: ""}` |
|
||||
| **Enable Teammates** | Enables the agent teams feature | Sets `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **Enable Tool Search** | Enables tool search functionality | Sets `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **High Effort** | Sets effort level to high | Sets `effortLevel = "high"` |
|
||||
| **Disable Auto Upgrade** | Prevents Claude Code auto-updates | Sets `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time.
|
||||
|
||||
Additionally, the **Write Common Config** checkbox enables merging a global config snippet into the provider. Click **Edit Common Config** to customize the shared snippet.
|
||||
|
||||
### Codex 1M Context Window
|
||||
|
||||
When adding a Codex provider, an **Enable 1M Context Window** toggle is available:
|
||||
|
||||
- **When enabled**: Sets `model_context_window = 1000000` and auto-fills `model_auto_compact_token_limit = 900000` in config.toml
|
||||
- **When disabled**: Removes both fields
|
||||
|
||||
The auto-compact limit can be customized in the text field that appears when the toggle is on.
|
||||
|
||||
### Custom Icon
|
||||
|
||||
Click the icon area to the left of the name to:
|
||||
|
||||
@@ -26,10 +26,12 @@ Quickly switch providers via the system tray without opening the main interface.
|
||||
### Steps
|
||||
|
||||
1. Right-click the CC Switch icon in the system tray
|
||||
2. Find the corresponding app (Claude/Codex/Gemini/OpenCode) in the menu
|
||||
2. Hover over the corresponding app submenu (e.g., "Claude · CurrentProvider")
|
||||
3. Click the provider name you want to switch to
|
||||
4. Switching completes with a brief tray notification
|
||||
|
||||
> Providers are organized into collapsible submenus by app type (Claude/Codex/Gemini). The submenu title shows the currently active provider name.
|
||||
|
||||
### Tray Menu Structure
|
||||
|
||||

|
||||
|
||||
@@ -52,6 +52,20 @@ When editing the currently active provider, a special "backfill" mechanism appli
|
||||
|
||||
This ensures CC Switch and CLI tool configurations stay in sync.
|
||||
|
||||
## Auto-Fetch Models
|
||||
|
||||
When editing a provider, you can auto-fetch the available model list from the provider's endpoint:
|
||||
|
||||
1. Ensure the API Key and endpoint URL are filled in
|
||||
2. Click the **Fetch Models** button (download icon) next to the model input field
|
||||
3. Select a model from the grouped dropdown
|
||||
|
||||
See [2.1 Add Provider — Auto-Fetch Models](./2.1-add.md#auto-fetch-models) for full details.
|
||||
|
||||
## Common Config Toggles (Claude)
|
||||
|
||||
When editing a Claude provider, quick toggle switches are available above the JSON editor for common settings like Tool Search, Disable Auto Upgrade, Teammates, and High Effort. See [2.1 Add Provider — Claude Common Config Toggles](./2.1-add.md#claude-common-config-toggles) for details.
|
||||
|
||||
## Modify API Key
|
||||
|
||||
When editing a provider, you can modify the key directly in the **API Key** input field:
|
||||
|
||||
@@ -118,8 +118,27 @@ Installation copies the skill folder to your local machine:
|
||||
|
||||
### Uninstall Effect
|
||||
|
||||
- Deletes the local skill folder
|
||||
- Updates installation status
|
||||
- **Automatic backup**: Before deletion, the skill is backed up to `~/.cc-switch/skill-backups/`
|
||||
- Removes the skill from all app directories (Claude, Codex, Gemini, OpenCode)
|
||||
- Removes the skill from the SSOT directory (`~/.cc-switch/skills/`)
|
||||
- Deletes the skill record from the database
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
If you need to restore a previously uninstalled skill:
|
||||
|
||||
1. Open the Skills page
|
||||
2. Click the **Restore from Backup** button
|
||||
3. Select the backup you want to restore from the list (shows skill name and backup date)
|
||||
4. The skill is restored and enabled for the current app
|
||||
|
||||
### Delete Backups
|
||||
|
||||
To remove old skill backups:
|
||||
|
||||
1. In the restore dialog, find the backup you want to remove
|
||||
2. Click the **Delete** button next to the backup entry
|
||||
3. Confirm deletion — this cannot be undone
|
||||
|
||||
## Repository Management
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 3.4 Session Manager
|
||||
|
||||
The Session Manager lets you browse, search, and manage conversation sessions from all supported CLI tools in one place.
|
||||
|
||||
## Supported Applications
|
||||
|
||||
| Application | Session Storage Location |
|
||||
|-------------|--------------------------|
|
||||
| Claude Code | `~/.cache/claude/projects/*.jsonl` |
|
||||
| Codex | Codex config sessions directory |
|
||||
| OpenCode | `~/.local/share/opencode/` (JSON or SQLite) |
|
||||
| OpenClaw | `~/.openclaw/agents/<agent>/sessions/*.jsonl` |
|
||||
| Gemini CLI | `~/.cache/gemini/tmp/<project_hash>/chats/` |
|
||||
|
||||
## Opening the Session Manager
|
||||
|
||||
Click the **Sessions** button in the main navigation bar toolbar.
|
||||
|
||||
> **Note**: The Sessions button is visible for all five supported applications.
|
||||
|
||||
## Interface Layout
|
||||
|
||||
The Session Manager uses a **two-column layout**:
|
||||
|
||||
- **Left panel**: Session list with search and filter toolbar
|
||||
- **Right panel**: Selected session details with conversation history
|
||||
|
||||
### Session List (Left Panel)
|
||||
|
||||
Each session entry displays:
|
||||
- Provider icon
|
||||
- Session title
|
||||
- Last active time (relative format, e.g., "5 min ago")
|
||||
|
||||
### Session Details (Right Panel)
|
||||
|
||||
When a session is selected, the right panel shows:
|
||||
- **Title**: Derived from session title, project directory name, or session ID
|
||||
- **Last active date/time**: Full timestamp
|
||||
- **Project directory**: Clickable to copy full path (shows basename with tooltip for full path)
|
||||
- **Resume command**: Displayed in monospace style when available
|
||||
- **Conversation history**: Full message transcript
|
||||
|
||||
## Search & Filtering
|
||||
|
||||
### Full-Text Search
|
||||
|
||||
Use the search box at the top of the left panel to search across:
|
||||
- Session ID
|
||||
- Title
|
||||
- Summary
|
||||
- Project directory
|
||||
- Source file path
|
||||
|
||||
The search supports prefix matching and filters results in real-time. Press **Esc** to clear the search.
|
||||
|
||||
### Provider Filtering
|
||||
|
||||
Click the provider filter dropdown (top-right of left panel) to filter by application:
|
||||
- **All** — Show sessions from all providers
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
- **OpenClaw**
|
||||
- **Gemini CLI**
|
||||
|
||||
The filter can be combined with search.
|
||||
|
||||
### Refresh
|
||||
|
||||
Click the refresh button (circular arrow icon) to re-scan all provider directories for new or deleted sessions.
|
||||
|
||||
## Session Actions
|
||||
|
||||
### Resume Session
|
||||
|
||||
Click the **Resume** button (play icon) on a selected session to continue the conversation.
|
||||
|
||||
**On macOS:**
|
||||
- CC Switch launches your preferred terminal with the resume command
|
||||
- The terminal opens in the session's project directory
|
||||
- If terminal launch fails, the command is copied to your clipboard instead
|
||||
|
||||
**Supported terminals (macOS):** Terminal.app, iTerm2, Ghostty, Kitty, WezTerm, Alacritty
|
||||
|
||||
**On other platforms:**
|
||||
- The resume command is copied to your clipboard
|
||||
- Paste it into your terminal to resume
|
||||
|
||||
> The Resume button is disabled if the session has no resume command available.
|
||||
|
||||
### Delete Session
|
||||
|
||||
Click the **Delete** button (trash icon) to permanently remove a session file. A confirmation dialog is shown before deletion.
|
||||
|
||||
> Sessions without a local source path (e.g., immutable sessions) cannot be deleted.
|
||||
|
||||
### Batch Operations
|
||||
|
||||
For managing multiple sessions at once:
|
||||
|
||||
1. Click the **Batch Mode** button (checkbox icon) in the left panel toolbar
|
||||
2. Select sessions using the checkboxes that appear
|
||||
3. Use **Select All** to select all filtered results, or **Clear** to deselect
|
||||
4. Click **Batch Delete** (red trash icon) to delete all selected sessions
|
||||
|
||||
A confirmation dialog shows the count before deletion. Results report the number of successful deletions and any failures.
|
||||
|
||||
## Conversation History
|
||||
|
||||
### Message Display
|
||||
|
||||
Messages are color-coded by role:
|
||||
- **User** messages: Green, left-aligned
|
||||
- **AI** (Assistant) messages: Blue, right-aligned
|
||||
- **System** messages: Amber
|
||||
- **Tool** messages: Purple
|
||||
|
||||
### Table of Contents
|
||||
|
||||
For longer conversations, a Table of Contents is available:
|
||||
- **Desktop (XL+ screens)**: Sidebar on the right showing user message previews
|
||||
- **Smaller screens**: Floating button (list icon) at bottom-right that opens a dialog
|
||||
|
||||
Click any entry to scroll to that message, which is briefly highlighted.
|
||||
|
||||
## Tips
|
||||
|
||||
- Sessions are sorted by last activity time (newest first)
|
||||
- The session count badge updates as you search and filter
|
||||
- OpenCode sessions may come from both JSON files and SQLite database — duplicates are automatically deduplicated
|
||||
@@ -0,0 +1,85 @@
|
||||
# 3.5 Workspace Files & Daily Memory
|
||||
|
||||
## Overview
|
||||
|
||||
The Workspace panel provides file management and daily memory features for **OpenClaw**. It allows you to edit workspace configuration files and maintain a daily memory journal.
|
||||
|
||||
> This feature is specific to OpenClaw. The Workspace button appears in the navigation bar when OpenClaw is the selected application.
|
||||
|
||||
## Workspace Files
|
||||
|
||||
### File Location
|
||||
|
||||
All workspace files are stored in `~/.openclaw/workspace/`.
|
||||
|
||||
Click the directory path at the top of the panel to open it in your file manager.
|
||||
|
||||
### Available Files
|
||||
|
||||
CC Switch manages 9 workspace files, each serving a specific purpose:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| **AGENTS.md** | Agents definition and configuration |
|
||||
| **SOUL.md** | System soul/personality settings |
|
||||
| **USER.md** | User profile information |
|
||||
| **IDENTITY.md** | Identity and role definition |
|
||||
| **TOOLS.md** | Available tools configuration |
|
||||
| **MEMORY.md** | System memory |
|
||||
| **HEARTBEAT.md** | Heartbeat configuration |
|
||||
| **BOOTSTRAP.md** | Bootstrap sequence |
|
||||
| **BOOT.md** | Boot configuration |
|
||||
|
||||
### File Status
|
||||
|
||||
Each file shows a status indicator:
|
||||
- **Green checkmark**: File exists on disk
|
||||
- **Empty circle**: File does not exist yet (will be created on first save)
|
||||
|
||||
### Editing Files
|
||||
|
||||
1. Click any file card to open the Markdown editor
|
||||
2. Edit the content
|
||||
3. Click **Save** to write changes to disk
|
||||
|
||||
If the file doesn't exist yet, it will be created on first save.
|
||||
|
||||
## Daily Memory
|
||||
|
||||
The Daily Memory feature provides a date-organized journal system stored in `~/.openclaw/workspace/memory/`.
|
||||
|
||||
### Accessing Daily Memory
|
||||
|
||||
Click the **Daily Memory** card in the Workspace Files grid to open the memory panel.
|
||||
|
||||
### File List
|
||||
|
||||
The panel displays all daily memory files sorted by date (newest first). Each entry shows:
|
||||
- **Date** (formatted from filename, e.g., `2026-04-01.md`)
|
||||
- **File size**
|
||||
- **Preview** (first 2 lines of content)
|
||||
|
||||
### Create Today's Note
|
||||
|
||||
Click the **Create Today** button to:
|
||||
- Open a new note with today's date (`YYYY-MM-DD.md`)
|
||||
- If today's note already exists, it opens for editing
|
||||
- The file is persisted only after you click Save
|
||||
|
||||
### Search
|
||||
|
||||
Search across all daily memory files:
|
||||
|
||||
1. Press **Cmd/Ctrl+F** or click the search icon
|
||||
2. Enter your search term
|
||||
3. Results show matching files with:
|
||||
- Match count per file
|
||||
- Snippet preview from the matching line
|
||||
- File date and size
|
||||
|
||||
Press **Esc** to close the search.
|
||||
|
||||
### Edit & Delete
|
||||
|
||||
- **Edit**: Click a file entry to open it in the Markdown editor
|
||||
- **Delete**: Hover over a file entry and click the delete icon. A confirmation dialog is shown — deletion cannot be undone.
|
||||
@@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1"
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
## API Format Conversion
|
||||
|
||||
The proxy supports automatic API format conversion for providers configured with non-Anthropic formats. This allows you to use providers that only support OpenAI-compatible APIs with Claude Code.
|
||||
|
||||
| Provider API Format | Proxy Behavior |
|
||||
|---------------------|----------------|
|
||||
| **Anthropic Messages** | Pass-through (no conversion) |
|
||||
| **OpenAI Chat Completions** | Converts Anthropic requests to OpenAI Chat format and responses back |
|
||||
| **OpenAI Responses API** | Converts Anthropic requests to OpenAI Responses format and responses back |
|
||||
|
||||
The API format is configured per-provider in the [Advanced Options](../2-providers/2.1-add.md#api-format-claude-only) when adding or editing a Claude provider.
|
||||
|
||||
> **Note**: Format conversion requires the proxy to be running with app takeover enabled. The conversion handles both streaming and non-streaming requests.
|
||||
|
||||
## Stop the Proxy
|
||||
|
||||
### Option 1: Main Interface Toggle
|
||||
|
||||
@@ -12,12 +12,11 @@ Customizable location in settings (for cloud sync).
|
||||
|
||||
```
|
||||
~/.cc-switch/
|
||||
├── cc-switch.db # SQLite database
|
||||
├── cc-switch.db # SQLite database (SSOT)
|
||||
├── settings.json # Device-level settings
|
||||
└── backups/ # Automatic backups
|
||||
├── backup-20251230-120000.json
|
||||
├── backup-20251229-180000.json
|
||||
└── ...
|
||||
├── skills/ # Skill SSOT directory
|
||||
├── skill-backups/ # Skill backups (created on uninstall)
|
||||
└── db_backup_*.db # Database backups
|
||||
```
|
||||
|
||||
### Database Contents
|
||||
|
||||
@@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage
|
||||
2. Manually download and install the latest version
|
||||
3. If using Homebrew: `brew upgrade --cask cc-switch`
|
||||
|
||||
## Lightweight Mode
|
||||
|
||||
### How to Enter Lightweight Mode?
|
||||
|
||||
Toggle "Lightweight Mode" from the system tray menu. The main window closes, and CC Switch runs as a tray-only app. Toggle again or click "Open main window" to exit.
|
||||
|
||||
### App Uses Less Memory in Lightweight Mode?
|
||||
|
||||
Yes. Lightweight Mode destroys the main window and its web view, reducing memory usage significantly while keeping tray menu functionality available.
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Submit an Issue
|
||||
|
||||
@@ -24,7 +24,9 @@ CC Switch User Manual
|
||||
├── 3. Extensions
|
||||
│ ├── 3.1 MCP Server Management
|
||||
│ ├── 3.2 Prompts Management
|
||||
│ └── 3.3 Skills Management
|
||||
│ ├── 3.3 Skills Management
|
||||
│ ├── 3.4 Session Manager
|
||||
│ └── 3.5 Workspace & Memory
|
||||
│
|
||||
├── 4. Proxy & High Availability
|
||||
│ ├── 4.1 Proxy Service
|
||||
@@ -69,6 +71,8 @@ CC Switch User Manual
|
||||
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP protocol, add servers, app binding |
|
||||
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | Create presets, activate/switch, smart backfill |
|
||||
| [3.3-skills.md](./3-extensions/3.3-skills.md) | Discover skills, install/uninstall, repository management |
|
||||
| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | Session Manager: browse, search, resume, delete sessions |
|
||||
| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | Workspace files and daily memory (OpenClaw) |
|
||||
|
||||
### 4. Proxy & High Availability
|
||||
|
||||
@@ -99,9 +103,9 @@ CC Switch User Manual
|
||||
|
||||
## Version Information
|
||||
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Applicable to CC Switch v3.12.0+
|
||||
- Documentation version: v3.12.3
|
||||
- Last updated: 2026-04-04
|
||||
- Applicable to CC Switch v3.12.3+
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -100,10 +100,13 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
| メニュー項目 | 機能 |
|
||||
|--------|------|
|
||||
| メインウィンドウを開く | メインウィンドウを表示してフォーカス |
|
||||
| アプリグループ | Claude/Codex/Gemini/OpenCode/OpenClaw ごとにプロバイダーを表示 |
|
||||
| プロバイダーリスト | クリックで切り替え、現在有効なものにはチェックマークを表示 |
|
||||
| アプリサブメニュー | Claude/Codex/Gemini/OpenCode/OpenClaw ごとの折りたたみサブメニュー(例:「Claude · PackyCode」) |
|
||||
| プロバイダーリスト | 各サブメニュー内でクリックして切り替え、現在有効なものにはチェックマークを表示 |
|
||||
| ライトウェイトモード | トグルチェックボックスでトレイ専用モードの開始/終了 |
|
||||
| 終了 | アプリを完全に終了 |
|
||||
|
||||
> **注意**:各アプリのサブメニュータイトルには現在のプロバイダー名が表示されます(例:「Claude · PackyCode」)。プロバイダーが設定されていないアプリでは、無効化された「(プロバイダーなし)」エントリが表示されます。アプリの表示はアプリの表示設定で制御されます。
|
||||
|
||||
### 多言語対応
|
||||
|
||||
トレイメニューは 3 つの言語に対応し、設定に応じて自動的に切り替わります:
|
||||
@@ -114,6 +117,17 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
| English | Open main window | Quit |
|
||||
| 日本語 | メインウィンドウを開く | 終了 |
|
||||
|
||||
### ライトウェイトモード
|
||||
|
||||
トレイメニューには **ライトウェイトモード** のトグル(チェックボックス)があります。有効にすると:
|
||||
|
||||
- メインウィンドウが閉じられ、リソースが解放される
|
||||
- アプリはシステムトレイのみで動作を継続
|
||||
- トレイのサブメニューからプロバイダーの切り替えが可能
|
||||
- macOS では Dock アイコンも非表示になる
|
||||
|
||||
ライトウェイトモードを終了するには、トグルのチェックを外すか「メインウィンドウを開く」をクリックします。メインウィンドウが再構築されて表示されます。
|
||||
|
||||
### 使用シーン
|
||||
|
||||
トレイからのプロバイダー切り替えはメイン画面を開く必要がなく、以下の場面に適しています:
|
||||
@@ -121,6 +135,7 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
- 頻繁にプロバイダーを切り替える場合
|
||||
- メインウィンドウが最小化されているときの素早い操作
|
||||
- バックグラウンド実行中の設定管理
|
||||
- ライトウェイトモードでリソース使用量を最小化
|
||||
|
||||
## 設定ページ
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
| アプリ | 反映方法 |
|
||||
|------|----------|
|
||||
| Claude Code | 即時反映(ホットリロード対応) |
|
||||
| Codex | ターミナルを閉じて再度開く必要あり |
|
||||
| Codex | ターミナルの再起動が必要 |
|
||||
| Gemini | 即時反映(リクエストごとに設定を再読み込み) |
|
||||
| OpenCode | ターミナルの再起動が必要 |
|
||||
| OpenClaw | ターミナルの再起動が必要 |
|
||||
|
||||
### Claude Code の初回インストール時の注意
|
||||
|
||||
@@ -64,6 +66,14 @@ codex
|
||||
# Gemini - 起動後にテスト質問を入力
|
||||
gemini
|
||||
> こんにちは、簡単に自己紹介してください
|
||||
|
||||
# OpenCode - 起動後にテスト質問を入力
|
||||
opencode
|
||||
> こんにちは、簡単に自己紹介してください
|
||||
|
||||
# OpenClaw - 起動後にテスト質問を入力
|
||||
openclaw
|
||||
> こんにちは、簡単に自己紹介してください
|
||||
```
|
||||
|
||||
AI が正常に回答すれば、設定は成功です。
|
||||
|
||||
@@ -167,37 +167,74 @@ CC Switch の送信 HTTP/HTTPS プロキシを設定します。外部 API に
|
||||
|
||||
### バックアップと復元
|
||||
|
||||
自動バックアップの管理:
|
||||
バックアップ管理パネルでは、データベースバックアップを完全に管理できます。
|
||||
|
||||
| 設定 | 説明 |
|
||||
| -------- | -------------------------- |
|
||||
| バックアップ間隔 | 自動バックアップの時間間隔(時間) |
|
||||
| 保持数量 | 保持するバックアップの数 |
|
||||
#### 自動バックアップ設定
|
||||
|
||||
バックアップリストの表示とバックアップからの復元をサポートします。
|
||||
| 設定 | オプション | デフォルト |
|
||||
|------|---------|---------|
|
||||
| バックアップ間隔 | 無効、6時間、12時間、24時間、48時間、7日 | 24時間 |
|
||||
| 保持数 | 3、5、10、15、20、30、50 | 10件 |
|
||||
|
||||
間隔を設定すると、CC Switch はスケジュールに従って自動的にデータベースをバックアップします。保持数を超えた古いバックアップは自動的に削除されます。
|
||||
|
||||
#### バックアップリスト
|
||||
|
||||
パネルには既存のすべてのバックアップが以下の情報とともに表示されます:
|
||||
- **表示名**(タイムスタンプから自動生成、例:`db_backup_20260315_143000`)
|
||||
- **作成日時**
|
||||
- **ファイルサイズ**(例:「1.5 MB」)
|
||||
|
||||
#### バックアップ操作
|
||||
|
||||
| 操作 | 説明 |
|
||||
|------|------|
|
||||
| **今すぐバックアップ** | 即座にバックアップを作成 |
|
||||
| **復元** | 選択したバックアップからデータベースを復元。復元前に現在のデータベースの安全バックアップが自動的に作成される |
|
||||
| **名前変更** | バックアップの表示名を変更 |
|
||||
| **削除** | バックアップを完全に削除(確認あり) |
|
||||
|
||||
> **重要**:バックアップの復元は現在のデータベースを上書きします。復元前に安全バックアップが自動作成されるため、必要に応じて元に戻すことができます。
|
||||
|
||||
### クラウド同期(WebDAV)
|
||||
|
||||
WebDAV プロトコルを使用して複数のデバイス間で設定を同期します。
|
||||
WebDAV プロトコルを使用して複数のデバイス間で設定を同期します。信頼性の向上のため、デュアルレイヤーバージョニングを備えた **v2 プロトコル** を使用しています。
|
||||
|
||||
| 設定項目 | 説明 |
|
||||
| -------- | ------------------------------------- |
|
||||
| サービスプリセット | 坚果云 / Nextcloud / Synology / カスタム |
|
||||
| サーバー URL | WebDAV サーバー URL |
|
||||
| ユーザー名 | ログインユーザー名 |
|
||||
| パスワード | ログインパスワード(アプリ専用パスワード) |
|
||||
| パスワード | ログインパスワード(アプリ専用パスワード、保存済みの場合は未変更で保持) |
|
||||
| リモートディレクトリ | リモート保存パス(デフォルト `cc-switch-sync`) |
|
||||
| プロファイル名 | デバイスプロファイル名(デフォルト `default`) |
|
||||
| 自動同期 | 有効にすると変更を自動アップロード |
|
||||
| 自動同期 | 設定可能な間隔で自動同期を有効化 |
|
||||
|
||||
操作:
|
||||
#### 操作
|
||||
|
||||
- **接続テスト**:WebDAV 設定が正しいか確認
|
||||
- **保存**:設定を保存して自動テスト
|
||||
- **アップロード**:ローカルデータをリモートにアップロード
|
||||
- **ダウンロード**:リモートからローカルにデータをダウンロード
|
||||
| 操作 | 説明 |
|
||||
|------|------|
|
||||
| **接続テスト** | WebDAV の URL、ユーザー名、パスワードが有効か確認 |
|
||||
| **アップロード** | ローカルデータベースをリモートにアップロード。進捗スピナーを表示 |
|
||||
| **ダウンロード** | リモートデータベースをダウンロード。確認前にリモートスナップショット情報(プロトコルバージョン、DB バージョン、タイムスタンプ、サイズ)を表示。上書き前にローカルデータベースの安全バックアップを自動作成 |
|
||||
|
||||
> **注意**:アップロードはリモートデータを、ダウンロードはローカルデータを上書きします。操作前にご確認ください。
|
||||
#### 自動同期
|
||||
|
||||
**自動同期** を有効にすると:
|
||||
- 初回有効化時に確認ダイアログが表示される
|
||||
- CC Switch は設定された間隔で自動的にデータベースを WebDAV に同期
|
||||
- 同期ステータスがパネルに表示される
|
||||
|
||||
#### リモートスナップショット情報
|
||||
|
||||
ダウンロード前に、CC Switch はリモートスナップショットの詳細を表示します:
|
||||
- プロトコルバージョン(v2)
|
||||
- データベース互換バージョン
|
||||
- リモートバックアップのタイムスタンプ
|
||||
- ファイルサイズ
|
||||
- 互換性ステータス(非互換の場合は警告)
|
||||
|
||||
> **注意**:アップロードはリモートデータを、ダウンロードはローカルデータを上書きします。ダウンロード前に安全バックアップが自動作成されます。
|
||||
|
||||
### ログ設定
|
||||
|
||||
|
||||
@@ -152,6 +152,22 @@
|
||||
| AWS Bedrock | AWS Bedrock サービス |
|
||||
| OpenAI Compatible | OpenAI 互換インターフェース |
|
||||
|
||||
## モデル自動取得
|
||||
|
||||
プロバイダーの追加や編集時に、プロバイダーのエンドポイントから利用可能なモデルを自動取得できます:
|
||||
|
||||
1. **API Key** と **エンドポイントアドレス** が入力されていることを確認
|
||||
2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック
|
||||
3. CC Switch がプロバイダーの `/v1/models` エンドポイントを呼び出してモデルリストを取得
|
||||
4. ベンダー別にグループ化されたドロップダウンからモデルを選択
|
||||
|
||||
この機能は、OpenAI 互換の `/v1/models` API をサポートするすべてのプロバイダーで動作します。Claude、Codex、Gemini、OpenCode、OpenClaw のプロバイダーで利用可能です。
|
||||
|
||||
**よくあるエラー:**
|
||||
- **認証失敗(401/403)**:API Key を確認してください
|
||||
- **エンドポイント未対応(404/405)**:プロバイダーが `/v1/models` エンドポイントを公開していません
|
||||
- **タイムアウト**:エンドポイントの応答が遅いです。後ほど再試行してください
|
||||
|
||||
## カスタム設定
|
||||
|
||||
「カスタム」プリセットを選択した場合、JSON 設定を手動で編集する必要があります。
|
||||
@@ -316,6 +332,45 @@ SQL バックアップファイルから一括インポート:
|
||||
|
||||
## 高度なオプション
|
||||
|
||||
### API フォーマット(Claude のみ)
|
||||
|
||||
サードパーティ API を使用する Claude プロバイダーを追加する際、高度なオプションセクションで正しい **API フォーマット** を選択する必要がある場合があります:
|
||||
|
||||
| フォーマット | 説明 | 使用場面 |
|
||||
|------|------|------|
|
||||
| **Anthropic Messages** | ネイティブ Anthropic API フォーマット(デフォルト) | Anthropic API に直接接続、または互換プロキシ |
|
||||
| **OpenAI Chat Completions** | OpenAI Chat API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Chat フォーマットのみ対応 |
|
||||
| **OpenAI Responses API** | OpenAI Responses API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Responses フォーマットのみ対応 |
|
||||
|
||||
> **注意**:API フォーマットの変換はプロキシサービスが担当します。Anthropic 以外のフォーマットを使用する場合、リクエスト/レスポンスの正しい変換のためにプロキシが接管有効の状態で稼働している必要があります。詳しくは [4.1 プロキシサービス](../4-proxy/4.1-service.md) をご覧ください。
|
||||
|
||||
デフォルト以外の API フォーマットが設定されている場合、高度なオプションセクションが自動展開されます。
|
||||
|
||||
### Claude 共通設定クイックトグル
|
||||
|
||||
Claude プロバイダーの編集時、JSON エディタの上部に **クイックトグル** が利用できます:
|
||||
|
||||
| トグル | 効果 | 設定変更 |
|
||||
|------|------|------|
|
||||
| **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: "", pr: ""}` を設定 |
|
||||
| **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` を設定 |
|
||||
| **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = "true"` を設定 |
|
||||
| **高強度** | エフォートレベルを高に設定 | `effortLevel = "high"` を設定 |
|
||||
| **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = "1"` を設定 |
|
||||
|
||||
トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。
|
||||
|
||||
また、**共通設定を書き込み** チェックボックスを有効にすると、グローバル設定スニペットをプロバイダーにマージできます。**共通設定を編集** をクリックして共有スニペットをカスタマイズできます。
|
||||
|
||||
### Codex 1M コンテキストウィンドウ
|
||||
|
||||
Codex プロバイダーの追加時、**1M コンテキストウィンドウを有効化** トグルが利用できます:
|
||||
|
||||
- **有効時**:config.toml に `model_context_window = 1000000` を設定し、`model_auto_compact_token_limit = 900000` を自動入力
|
||||
- **無効時**:両方のフィールドを削除
|
||||
|
||||
トグルがオンの場合に表示されるテキストフィールドで、自動コンパクト制限をカスタマイズできます。
|
||||
|
||||
### カスタムアイコン
|
||||
|
||||
名前の左側にあるアイコンエリアをクリックすると:
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
### 操作手順
|
||||
|
||||
1. システムトレイの CC Switch アイコンを右クリック
|
||||
2. メニューで対応するアプリ(Claude/Codex/Gemini/OpenCode)を見つける
|
||||
2. 対応するアプリのサブメニュー(例:「Claude · 現在のプロバイダー」)にマウスを合わせる
|
||||
3. 切り替えたいプロバイダー名をクリック
|
||||
4. 切り替え完了、トレイに短い通知が表示
|
||||
|
||||
> プロバイダーはアプリタイプ(Claude/Codex/Gemini)ごとに折りたたみサブメニューに整理されています。サブメニューのタイトルには現在有効なプロバイダー名が表示されます。
|
||||
|
||||
### トレイメニュー構造
|
||||
|
||||

|
||||
|
||||
@@ -52,6 +52,20 @@ JSON 形式の設定内容(以下を含む):
|
||||
|
||||
これにより、CC Switch と CLI ツールの設定が常に同期されます。
|
||||
|
||||
## モデル自動取得
|
||||
|
||||
プロバイダーの編集時に、プロバイダーのエンドポイントから利用可能なモデルリストを自動取得できます:
|
||||
|
||||
1. API Key とエンドポイントアドレスが入力されていることを確認
|
||||
2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック
|
||||
3. グループ化されたドロップダウンからモデルを選択
|
||||
|
||||
詳しくは [2.1 プロバイダーの追加 - モデル自動取得](./2.1-add.md#モデル自動取得) をご覧ください。
|
||||
|
||||
## 共通設定クイックトグル(Claude)
|
||||
|
||||
Claude プロバイダーの編集時、JSON エディタの上部にツール検索、自動アップグレード無効化、チームメイト、高強度などの共通設定のクイックトグルスイッチが利用できます。詳しくは [2.1 プロバイダーの追加 - Claude 共通設定クイックトグル](./2.1-add.md#claude-共通設定クイックトグル) をご覧ください。
|
||||
|
||||
## API Key の変更
|
||||
|
||||
プロバイダーの編集時に、**API Key** 入力ボックスから直接変更できます:
|
||||
|
||||
@@ -118,8 +118,27 @@ CC Switch は強力な検索とフィルタリング機能を提供していま
|
||||
|
||||
### アンインストールの効果
|
||||
|
||||
- ローカルのスキルフォルダを削除
|
||||
- インストール状態を更新
|
||||
- **自動バックアップ**:削除前にスキルが `~/.cc-switch/skill-backups/` にバックアップされる
|
||||
- すべてのアプリディレクトリ(Claude、Codex、Gemini、OpenCode)からスキルを削除
|
||||
- SSOT ディレクトリ(`~/.cc-switch/skills/`)からスキルを削除
|
||||
- データベースからスキルレコードを削除
|
||||
|
||||
### バックアップから復元
|
||||
|
||||
以前アンインストールしたスキルを復元する場合:
|
||||
|
||||
1. Skills ページを開く
|
||||
2. **バックアップから復元** ボタンをクリック
|
||||
3. リスト(スキル名とバックアップ日が表示)から復元したいバックアップを選択
|
||||
4. スキルが復元され、現在のアプリで有効化される
|
||||
|
||||
### バックアップの削除
|
||||
|
||||
古いスキルバックアップを削除するには:
|
||||
|
||||
1. 復元ダイアログで削除したいバックアップを見つける
|
||||
2. バックアップエントリの横にある **削除** ボタンをクリック
|
||||
3. 削除を確認(この操作は取り消せません)
|
||||
|
||||
## リポジトリ管理
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 3.4 セッションマネージャー
|
||||
|
||||
セッションマネージャーでは、対応するすべての CLI ツールの会話セッションを一か所で閲覧、検索、管理できます。
|
||||
|
||||
## 対応アプリ
|
||||
|
||||
| アプリ | セッション保存場所 |
|
||||
|------|----------|
|
||||
| Claude Code | `~/.cache/claude/projects/*.jsonl` |
|
||||
| Codex | Codex 設定のセッションディレクトリ |
|
||||
| OpenCode | `~/.local/share/opencode/`(JSON または SQLite) |
|
||||
| OpenClaw | `~/.openclaw/agents/<agent>/sessions/*.jsonl` |
|
||||
| Gemini CLI | `~/.cache/gemini/tmp/<project_hash>/chats/` |
|
||||
|
||||
## セッションマネージャーを開く
|
||||
|
||||
メインナビゲーションバーの **セッション** ボタンをクリックします。
|
||||
|
||||
> **注意**:セッションボタンは対応する 5 つのアプリすべてで表示されます。
|
||||
|
||||
## インターフェースのレイアウト
|
||||
|
||||
セッションマネージャーは **2 カラムレイアウト** を採用しています:
|
||||
|
||||
- **左パネル**:検索とフィルターツールバー付きのセッションリスト
|
||||
- **右パネル**:選択したセッションの詳細と会話履歴
|
||||
|
||||
### セッションリスト(左パネル)
|
||||
|
||||
各セッションエントリには以下が表示されます:
|
||||
- プロバイダーアイコン
|
||||
- セッションタイトル
|
||||
- 最終アクティブ時間(相対形式、例:「5分前」)
|
||||
|
||||
### セッション詳細(右パネル)
|
||||
|
||||
セッションを選択すると、右パネルに以下が表示されます:
|
||||
- **タイトル**:セッションタイトル、プロジェクトディレクトリ名、またはセッション ID から取得
|
||||
- **最終アクティブ日時**:完全なタイムスタンプ
|
||||
- **プロジェクトディレクトリ**:クリックでフルパスをコピー(ベース名とツールチップでフルパスを表示)
|
||||
- **再開コマンド**:利用可能な場合、モノスペースフォントで表示
|
||||
- **会話履歴**:メッセージの全文記録
|
||||
|
||||
## 検索とフィルタリング
|
||||
|
||||
### 全文検索
|
||||
|
||||
左パネル上部の検索ボックスを使用して、以下の項目を横断的に検索できます:
|
||||
- セッション ID
|
||||
- タイトル
|
||||
- サマリー
|
||||
- プロジェクトディレクトリ
|
||||
- ソースファイルパス
|
||||
|
||||
前方一致検索に対応し、リアルタイムで結果をフィルタリングします。**Esc** で検索をクリアできます。
|
||||
|
||||
### プロバイダーフィルター
|
||||
|
||||
左パネル右上のプロバイダーフィルタードロップダウンをクリックして、アプリ別にフィルタリングします:
|
||||
- **すべて** — すべてのプロバイダーのセッションを表示
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
- **OpenClaw**
|
||||
- **Gemini CLI**
|
||||
|
||||
フィルターは検索と組み合わせて使用できます。
|
||||
|
||||
### 更新
|
||||
|
||||
更新ボタン(循環矢印アイコン)をクリックすると、すべてのプロバイダーディレクトリを再スキャンして新しいセッションや削除済みセッションを検出します。
|
||||
|
||||
## セッション操作
|
||||
|
||||
### セッションの再開
|
||||
|
||||
選択したセッションの **再開** ボタン(再生アイコン)をクリックして、会話を続行します。
|
||||
|
||||
**macOS の場合:**
|
||||
- CC Switch は設定済みのターミナルで再開コマンドを起動します
|
||||
- ターミナルはセッションのプロジェクトディレクトリで開きます
|
||||
- ターミナルの起動に失敗した場合、コマンドがクリップボードにコピーされます
|
||||
|
||||
**対応ターミナル(macOS):** Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty
|
||||
|
||||
**その他のプラットフォーム:**
|
||||
- 再開コマンドがクリップボードにコピーされます
|
||||
- ターミナルに貼り付けてセッションを再開してください
|
||||
|
||||
> 再開コマンドが利用できないセッションでは、再開ボタンは無効になります。
|
||||
|
||||
### セッションの削除
|
||||
|
||||
**削除** ボタン(ゴミ箱アイコン)をクリックすると、セッションファイルが完全に削除されます。削除前に確認ダイアログが表示されます。
|
||||
|
||||
> ローカルソースパスのないセッション(不変のセッションなど)は削除できません。
|
||||
|
||||
### 一括操作
|
||||
|
||||
複数のセッションを一度に管理するには:
|
||||
|
||||
1. 左パネルツールバーの **一括モード** ボタン(チェックボックスアイコン)をクリック
|
||||
2. 表示されるチェックボックスでセッションを選択
|
||||
3. **すべて選択** でフィルタリング結果をすべて選択、または **クリア** で選択解除
|
||||
4. **一括削除**(赤いゴミ箱アイコン)をクリックして選択したすべてのセッションを削除
|
||||
|
||||
削除前に件数を表示する確認ダイアログが表示されます。結果には成功した削除件数と失敗件数が報告されます。
|
||||
|
||||
## 会話履歴
|
||||
|
||||
### メッセージの表示
|
||||
|
||||
メッセージは役割ごとに色分けされます:
|
||||
- **ユーザー** メッセージ:緑、左寄せ
|
||||
- **AI**(アシスタント)メッセージ:青、右寄せ
|
||||
- **システム** メッセージ:アンバー
|
||||
- **ツール** メッセージ:パープル
|
||||
|
||||
### 目次
|
||||
|
||||
長い会話の場合、目次機能が利用できます:
|
||||
- **デスクトップ(XL+ 画面)**:右側のサイドバーにユーザーメッセージのプレビューを表示
|
||||
- **小さい画面**:右下のフローティングボタン(リストアイコン)をクリックするとダイアログが開く
|
||||
|
||||
エントリをクリックすると該当メッセージにスクロールし、一時的にハイライト表示されます。
|
||||
|
||||
## ヒント
|
||||
|
||||
- セッションは最終アクティブ時間の新しい順にソートされます
|
||||
- セッション数バッジは検索やフィルタリングに応じて更新されます
|
||||
- OpenCode のセッションは JSON ファイルと SQLite データベースの両方から取得される場合があります(重複は自動的に除去されます)
|
||||
@@ -0,0 +1,85 @@
|
||||
# 3.5 ワークスペースファイルとデイリーメモリー
|
||||
|
||||
## 概要
|
||||
|
||||
ワークスペースパネルは、**OpenClaw** 向けのファイル管理とデイリーメモリー機能を提供します。ワークスペース設定ファイルの編集やデイリーメモリージャーナルの管理が可能です。
|
||||
|
||||
> この機能は OpenClaw 専用です。ワークスペースボタンは、OpenClaw が選択されている場合にナビゲーションバーに表示されます。
|
||||
|
||||
## ワークスペースファイル
|
||||
|
||||
### ファイルの保存場所
|
||||
|
||||
すべてのワークスペースファイルは `~/.openclaw/workspace/` に保存されます。
|
||||
|
||||
パネル上部のディレクトリパスをクリックすると、ファイルマネージャーで開きます。
|
||||
|
||||
### 利用可能なファイル
|
||||
|
||||
CC Switch は 9 つのワークスペースファイルを管理しており、それぞれ特定の役割を持っています:
|
||||
|
||||
| ファイル | 説明 |
|
||||
|------|------|
|
||||
| **AGENTS.md** | エージェントの定義と設定 |
|
||||
| **SOUL.md** | システムのソウル/パーソナリティ設定 |
|
||||
| **USER.md** | ユーザープロファイル情報 |
|
||||
| **IDENTITY.md** | アイデンティティとロールの定義 |
|
||||
| **TOOLS.md** | 利用可能なツールの設定 |
|
||||
| **MEMORY.md** | システムメモリー |
|
||||
| **HEARTBEAT.md** | ハートビート設定 |
|
||||
| **BOOTSTRAP.md** | ブートストラップシーケンス |
|
||||
| **BOOT.md** | ブート設定 |
|
||||
|
||||
### ファイルステータス
|
||||
|
||||
各ファイルにはステータスインジケーターが表示されます:
|
||||
- **緑のチェックマーク**:ファイルがディスク上に存在
|
||||
- **空の円**:ファイルがまだ存在しない(初回保存時に作成)
|
||||
|
||||
### ファイルの編集
|
||||
|
||||
1. ファイルカードをクリックして Markdown エディタを開く
|
||||
2. コンテンツを編集
|
||||
3. **保存** をクリックしてディスクに書き込み
|
||||
|
||||
ファイルがまだ存在しない場合は、初回保存時に作成されます。
|
||||
|
||||
## デイリーメモリー
|
||||
|
||||
デイリーメモリー機能は、`~/.openclaw/workspace/memory/` に保存される日付別のジャーナルシステムを提供します。
|
||||
|
||||
### デイリーメモリーへのアクセス
|
||||
|
||||
ワークスペースファイルグリッドの **デイリーメモリー** カードをクリックして、メモリーパネルを開きます。
|
||||
|
||||
### ファイルリスト
|
||||
|
||||
パネルには日付の新しい順にすべてのデイリーメモリーファイルが表示されます。各エントリには以下が表示されます:
|
||||
- **日付**(ファイル名から変換、例:`2026-04-01.md`)
|
||||
- **ファイルサイズ**
|
||||
- **プレビュー**(コンテンツの最初の 2 行)
|
||||
|
||||
### 今日のノートを作成
|
||||
|
||||
**今日のノートを作成** ボタンをクリックすると:
|
||||
- 今日の日付(`YYYY-MM-DD.md`)で新しいノートを開く
|
||||
- 今日のノートがすでに存在する場合は、編集用に開く
|
||||
- ファイルは保存をクリックした後に保存される
|
||||
|
||||
### 検索
|
||||
|
||||
すべてのデイリーメモリーファイルを横断検索できます:
|
||||
|
||||
1. **Cmd/Ctrl+F** を押すか、検索アイコンをクリック
|
||||
2. 検索語を入力
|
||||
3. 結果には以下が表示されます:
|
||||
- ファイルごとのマッチ数
|
||||
- マッチした行のスニペットプレビュー
|
||||
- ファイルの日付とサイズ
|
||||
|
||||
**Esc** で検索を閉じます。
|
||||
|
||||
### 編集と削除
|
||||
|
||||
- **編集**:ファイルエントリをクリックして Markdown エディタで開く
|
||||
- **削除**:ファイルエントリにマウスをホバーして削除アイコンをクリック。確認ダイアログが表示されます(削除は取り消せません)。
|
||||
@@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1"
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
## API フォーマット変換
|
||||
|
||||
プロキシは、Anthropic 以外のフォーマットが設定されたプロバイダーに対して、API フォーマットの自動変換をサポートします。これにより、OpenAI 互換 API のみをサポートするプロバイダーを Claude Code で使用できます。
|
||||
|
||||
| プロバイダー API フォーマット | プロキシの動作 |
|
||||
|------|------|
|
||||
| **Anthropic Messages** | パススルー(変換なし) |
|
||||
| **OpenAI Chat Completions** | Anthropic リクエストを OpenAI Chat フォーマットに変換し、レスポンスを逆変換 |
|
||||
| **OpenAI Responses API** | Anthropic リクエストを OpenAI Responses フォーマットに変換し、レスポンスを逆変換 |
|
||||
|
||||
API フォーマットはプロバイダーごとに、Claude プロバイダーの追加・編集時の[高度なオプション](../2-providers/2.1-add.md#api-フォーマットclaude-のみ)で設定します。
|
||||
|
||||
> **注意**:フォーマット変換にはプロキシがアプリ接管有効の状態で稼働している必要があります。変換はストリーミングと非ストリーミングの両方のリクエストに対応しています。
|
||||
|
||||
## プロキシの停止
|
||||
|
||||
### 方法 1:メイン画面のスイッチ
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
|
||||
```
|
||||
~/.cc-switch/
|
||||
├── cc-switch.db # SQLite データベース
|
||||
├── cc-switch.db # SQLite データベース(SSOT)
|
||||
├── settings.json # デバイスレベルの設定
|
||||
└── backups/ # 自動バックアップ
|
||||
├── backup-20251230-120000.json
|
||||
├── backup-20251229-180000.json
|
||||
└── ...
|
||||
├── skills/ # スキル SSOT ディレクトリ
|
||||
├── skill-backups/ # スキルバックアップ(アンインストール時に作成)
|
||||
└── db_backup_*.db # データベースバックアップ
|
||||
```
|
||||
|
||||
### データベースの内容
|
||||
|
||||
@@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage
|
||||
2. 最新版を手動でダウンロードしてインストール
|
||||
3. Homebrew を使用する場合:`brew upgrade --cask cc-switch`
|
||||
|
||||
## ライトウェイトモード
|
||||
|
||||
### ライトウェイトモードに入るには?
|
||||
|
||||
システムトレイメニューから「ライトウェイトモード」をトグルします。メインウィンドウが閉じ、CC Switch はトレイ専用アプリとして動作します。再度トグルするか「メインウィンドウを開く」をクリックすると終了します。
|
||||
|
||||
### ライトウェイトモードではメモリ使用量が少なくなる?
|
||||
|
||||
はい。ライトウェイトモードではメインウィンドウとその Web ビューを破棄するため、トレイメニュー機能を維持しながらメモリ使用量を大幅に削減します。
|
||||
|
||||
## ヘルプの入手
|
||||
|
||||
### Issue の提出
|
||||
|
||||
@@ -24,7 +24,9 @@ CC Switch ユーザーマニュアル
|
||||
├── 3. 拡張機能
|
||||
│ ├── 3.1 MCP サーバー管理
|
||||
│ ├── 3.2 Prompts プロンプト管理
|
||||
│ └── 3.3 Skills スキル管理
|
||||
│ ├── 3.3 Skills スキル管理
|
||||
│ ├── 3.4 セッションマネージャー
|
||||
│ └── 3.5 ワークスペースとメモリー
|
||||
│
|
||||
├── 4. プロキシと高可用性
|
||||
│ ├── 4.1 プロキシサービス
|
||||
@@ -69,6 +71,8 @@ CC Switch ユーザーマニュアル
|
||||
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP プロトコル、サーバーの追加、アプリバインド |
|
||||
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | プリセットの作成、有効化の切り替え、スマートバックフィル |
|
||||
| [3.3-skills.md](./3-extensions/3.3-skills.md) | スキルの発見、インストール・アンインストール、リポジトリ管理 |
|
||||
| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | セッションマネージャー:閲覧、検索、再開、削除 |
|
||||
| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | ワークスペースファイルとデイリーメモリー(OpenClaw) |
|
||||
|
||||
### 4. プロキシと高可用性
|
||||
|
||||
@@ -99,9 +103,9 @@ CC Switch ユーザーマニュアル
|
||||
|
||||
## バージョン情報
|
||||
|
||||
- ドキュメントバージョン:v3.12.0
|
||||
- 最終更新:2026-03-09
|
||||
- CC Switch v3.12.0+ 対応
|
||||
- ドキュメントバージョン:v3.12.3
|
||||
- 最終更新:2026-04-04
|
||||
- CC Switch v3.12.3+ 対応
|
||||
|
||||
## コントリビュート
|
||||
|
||||
|
||||
@@ -100,10 +100,13 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
| 菜单项 | 功能 |
|
||||
|--------|------|
|
||||
| 打开主界面 | 显示主窗口并聚焦 |
|
||||
| 应用分组 | 按 Claude/Codex/Gemini/OpenCode/OpenClaw 分组显示供应商 |
|
||||
| 供应商列表 | 点击切换,当前启用的显示勾选标记 |
|
||||
| 应用子菜单 | 按 Claude/Codex/Gemini/OpenCode/OpenClaw 分组的折叠子菜单(如 "Claude · PackyCode") |
|
||||
| 供应商列表 | 在每个子菜单内,点击切换,当前启用的显示勾选标记 |
|
||||
| 轻量模式 | 勾选框切换,进入/退出仅托盘运行模式 |
|
||||
| 退出 | 完全退出应用 |
|
||||
|
||||
> **注意**:每个应用子菜单的标题会显示当前供应商名称(如 "Claude · PackyCode")。没有配置供应商的应用会显示禁用的"(无供应商)"条目。应用可见性由设置中的"应用可见性"选项控制。
|
||||
|
||||
### 多语言支持
|
||||
|
||||
托盘菜单支持三种语言,根据设置自动切换:
|
||||
@@ -114,6 +117,17 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
| English | Open main window | Quit |
|
||||
| 日本語 | メインウィンドウを開く | 終了 |
|
||||
|
||||
### 轻量模式
|
||||
|
||||
托盘菜单包含 **轻量模式** 切换开关(勾选框)。启用后:
|
||||
|
||||
- 主窗口关闭以释放资源
|
||||
- 应用仅在系统托盘中运行
|
||||
- 你仍可通过托盘子菜单切换供应商
|
||||
- 在 macOS 上,Dock 图标也会隐藏
|
||||
|
||||
要退出轻量模式,取消勾选该选项或点击"打开主界面" — 主窗口将被重建并显示。
|
||||
|
||||
### 使用场景
|
||||
|
||||
托盘切换供应商无需打开主界面,适合:
|
||||
@@ -121,6 +135,7 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
- 频繁切换供应商
|
||||
- 主窗口最小化时快速操作
|
||||
- 后台运行时管理配置
|
||||
- 使用轻量模式以最小化资源占用
|
||||
|
||||
## 设置页面
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
| 应用 | 生效方式 |
|
||||
|------|----------|
|
||||
| Claude Code | ✅ 即时生效(支持热重载) |
|
||||
| Codex | 需关闭并重新打开终端 |
|
||||
| Codex | 需要关闭并重新打开终端 |
|
||||
| Gemini | ✅ 即时生效(每次请求重新读取配置) |
|
||||
| OpenCode | 需要关闭并重新打开终端 |
|
||||
| OpenClaw | 需要关闭并重新打开终端 |
|
||||
|
||||
### Claude Code 首次安装提示
|
||||
|
||||
@@ -64,6 +66,14 @@ codex
|
||||
# Gemini - 启动后输入测试问题
|
||||
gemini
|
||||
> 你好,请简单介绍一下自己
|
||||
|
||||
# OpenCode - 启动后输入测试问题
|
||||
opencode
|
||||
> 你好,请简单介绍一下自己
|
||||
|
||||
# OpenClaw - 启动后输入测试问题
|
||||
openclaw
|
||||
> 你好,请简单介绍一下自己
|
||||
```
|
||||
|
||||
如果 AI 能正常回复,说明配置成功。
|
||||
|
||||
@@ -167,37 +167,74 @@ CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。
|
||||
|
||||
### 备份与恢复
|
||||
|
||||
管理自动备份:
|
||||
备份管理面板提供对数据库备份的全面控制。
|
||||
|
||||
| 配置 | 说明 |
|
||||
| -------- | -------------------------- |
|
||||
| 备份间隔 | 自动备份的时间间隔(小时) |
|
||||
| 保留数量 | 保留的备份份数 |
|
||||
#### 自动备份设置
|
||||
|
||||
支持查看备份列表和从备份恢复。
|
||||
| 配置 | 选项 | 默认值 |
|
||||
| -------- | ---------------------------------- | --------- |
|
||||
| 备份间隔 | 禁用、6h、12h、24h、48h、7d | 24 小时 |
|
||||
| 保留数量 | 3、5、10、15、20、30、50 | 10 个备份 |
|
||||
|
||||
设置间隔后,CC Switch 会按计划自动备份数据库。超出保留数量的旧备份会自动删除。
|
||||
|
||||
#### 备份列表
|
||||
|
||||
面板显示所有现有备份,包含:
|
||||
- **显示名称**(根据时间戳自动生成,如 `db_backup_20260315_143000`)
|
||||
- **创建时间**
|
||||
- **文件大小**(如 "1.5 MB")
|
||||
|
||||
#### 备份操作
|
||||
|
||||
| 操作 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------ |
|
||||
| **立即备份** | 立即创建一个备份 |
|
||||
| **恢复** | 从选定的备份恢复数据库。恢复前会自动创建当前数据库的安全备份 |
|
||||
| **重命名** | 修改备份的显示名称 |
|
||||
| **删除** | 永久删除备份(需确认) |
|
||||
|
||||
> ⚠️ **重要**:恢复备份会覆盖当前数据库。恢复操作前始终会自动创建安全备份,以便在需要时恢复。
|
||||
|
||||
### 云同步(WebDAV)
|
||||
|
||||
通过 WebDAV 协议在多台设备间同步配置。
|
||||
通过 WebDAV 协议在多台设备间同步配置。使用 **v2 协议**,支持双层版本控制,提高可靠性。
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| -------- | ------------------------------------- |
|
||||
| 服务预设 | 坚果云 / Nextcloud / 群晖 / 自定义 |
|
||||
| 服务地址 | WebDAV 服务器 URL |
|
||||
| 用户名 | 登录用户名 |
|
||||
| 密码 | 登录密码(应用专用密码) |
|
||||
| 远程目录 | 远程存储路径(默认 `cc-switch-sync`) |
|
||||
| 配置名称 | 设备配置文件名(默认 `default`) |
|
||||
| 自动同步 | 开启后自动上传变更 |
|
||||
| 配置项 | 说明 |
|
||||
| -------- | ---------------------------------------------------------------------------- |
|
||||
| 服务预设 | 坚果云 / Nextcloud / 群晖 / 自定义 |
|
||||
| 服务地址 | WebDAV 服务器 URL |
|
||||
| 用户名 | 登录用户名 |
|
||||
| 密码 | 登录密码(应用专用密码;已保存的凭据在未修改时会被保留) |
|
||||
| 远程目录 | 远程存储路径(默认 `cc-switch-sync`) |
|
||||
| 配置名称 | 设备配置文件名(默认 `default`) |
|
||||
| 自动同步 | 开启后按配置的间隔自动同步 |
|
||||
|
||||
操作:
|
||||
#### 操作
|
||||
|
||||
- **测试连接**:验证 WebDAV 配置是否正确
|
||||
- **保存**:保存配置并自动测试
|
||||
- **上传**:将本地数据上传到远程
|
||||
- **下载**:从远程下载数据到本地
|
||||
| 操作 | 说明 |
|
||||
| ------------ | -------------------------------------------------------------------------------------------- |
|
||||
| **测试连接** | 验证 WebDAV URL、用户名和密码是否有效 |
|
||||
| **上传** | 将本地数据库上传到远程,显示进度指示器 |
|
||||
| **下载** | 下载远程数据库。下载前显示远程快照信息(协议版本、数据库版本、时间戳、大小)供确认。覆盖本地数据库前会自动创建安全备份 |
|
||||
|
||||
> ⚠️ **注意**:上传会覆盖远程数据,下载会覆盖本地数据。操作前请确认。
|
||||
#### 自动同步
|
||||
|
||||
启用 **自动同步** 后:
|
||||
- 首次激活时会显示确认对话框
|
||||
- CC Switch 按配置的间隔自动将数据库同步到 WebDAV
|
||||
- 面板中显示同步状态
|
||||
|
||||
#### 远程快照信息
|
||||
|
||||
下载前,CC Switch 会显示远程快照的详细信息:
|
||||
- 协议版本(v2)
|
||||
- 数据库兼容版本
|
||||
- 远程备份的时间戳
|
||||
- 文件大小
|
||||
- 兼容性状态(不兼容时显示警告)
|
||||
|
||||
> ⚠️ **注意**:上传会覆盖远程数据,下载会覆盖本地数据。下载前始终会自动创建安全备份。
|
||||
|
||||
### 日志配置
|
||||
|
||||
|
||||
@@ -152,6 +152,22 @@
|
||||
| AWS Bedrock | AWS Bedrock 服务 |
|
||||
| OpenAI Compatible | OpenAI 兼容接口 |
|
||||
|
||||
## 自动获取模型
|
||||
|
||||
添加或编辑供应商时,可以自动从供应商端点获取可用模型列表:
|
||||
|
||||
1. 确保已填写 **API Key** 和 **端点地址**
|
||||
2. 点击模型输入框旁的 **获取模型** 按钮(下载图标)
|
||||
3. CC Switch 调用供应商的 `/v1/models` 端点获取模型列表
|
||||
4. 从按供应商分组的下拉菜单中选择模型
|
||||
|
||||
此功能适用于所有支持 OpenAI 兼容 `/v1/models` API 的供应商。Claude、Codex、Gemini、OpenCode 和 OpenClaw 供应商均可使用。
|
||||
|
||||
**常见错误**:
|
||||
- **认证失败(401/403)**:检查你的 API Key
|
||||
- **端点不支持(404/405)**:该供应商未提供 `/v1/models` 端点
|
||||
- **超时**:端点响应缓慢,请稍后重试
|
||||
|
||||
## 自定义配置
|
||||
|
||||
选择「自定义」预设后,需要手动编辑 JSON 配置。
|
||||
@@ -316,6 +332,45 @@ CC Switch 支持两种方式导入供应商配置:
|
||||
|
||||
## 高级选项
|
||||
|
||||
### API 格式(仅 Claude)
|
||||
|
||||
添加使用第三方 API 的 Claude 供应商时,可能需要在高级选项中选择正确的 **API 格式**:
|
||||
|
||||
| 格式 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **Anthropic Messages** | 原生 Anthropic API 格式(默认) | 直接 Anthropic API 或兼容代理 |
|
||||
| **OpenAI Chat Completions** | OpenAI Chat API 格式,由代理自动转换 | 供应商仅支持 OpenAI Chat 格式 |
|
||||
| **OpenAI Responses API** | OpenAI Responses API 格式,由代理自动转换 | 供应商仅支持 OpenAI Responses 格式 |
|
||||
|
||||
> **注意**:API 格式转换由代理服务处理。使用非 Anthropic 格式时,需要开启代理并启用应用接管才能正确转换请求/响应。详见 [4.1 代理服务](../4-proxy/4.1-service.md)。
|
||||
|
||||
当配置了非默认 API 格式时,高级选项区域会自动展开。
|
||||
|
||||
### Claude 通用配置快捷开关
|
||||
|
||||
编辑 Claude 供应商时,JSON 编辑器上方提供一组 **快捷开关**:
|
||||
|
||||
| 开关 | 效果 | 配置变更 |
|
||||
|------|------|----------|
|
||||
| **隐藏署名** | 清除提交/PR 的署名元数据 | 设置 `attribution: {commit: "", pr: ""}` |
|
||||
| **启用 Teammates** | 启用 Agent 团队功能 | 设置 `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **启用工具搜索** | 启用工具搜索功能 | 设置 `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **高效能模式** | 将 effort 级别设为 high | 设置 `effortLevel = "high"` |
|
||||
| **禁用自动更新** | 阻止 Claude Code 自动更新 | 设置 `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。
|
||||
|
||||
此外,**写入通用配置** 复选框可将全局配置片段合并到供应商中。点击 **编辑通用配置** 可自定义共享的配置片段。
|
||||
|
||||
### Codex 1M 上下文窗口
|
||||
|
||||
添加 Codex 供应商时,提供 **启用 1M 上下文窗口** 开关:
|
||||
|
||||
- **启用时**:在 config.toml 中设置 `model_context_window = 1000000` 并自动填充 `model_auto_compact_token_limit = 900000`
|
||||
- **禁用时**:移除这两个字段
|
||||
|
||||
开关开启后显示的文本框可自定义自动压缩限制值。
|
||||
|
||||
### 自定义图标
|
||||
|
||||
点击名称左侧的图标区域,可以:
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
### 操作步骤
|
||||
|
||||
1. 右键点击系统托盘的 CC Switch 图标
|
||||
2. 在菜单中找到对应应用(Claude/Codex/Gemini/OpenCode)
|
||||
3. 点击要切换的供应商名称
|
||||
2. 将鼠标悬停在对应应用的子菜单上(如 "Claude · 当前供应商")
|
||||
3. 点击要切换到的供应商名称
|
||||
4. 切换完成,托盘会短暂提示
|
||||
|
||||
> 供应商按应用类型(Claude/Codex/Gemini)组织到折叠子菜单中。子菜单标题显示当前激活的供应商名称。
|
||||
|
||||
### 托盘菜单结构
|
||||
|
||||

|
||||
|
||||
@@ -52,6 +52,20 @@ JSON 格式的配置内容,包括:
|
||||
|
||||
这确保了 CC Switch 和 CLI 工具的配置始终同步。
|
||||
|
||||
## 自动获取模型
|
||||
|
||||
编辑供应商时,可以自动从供应商端点获取可用模型列表:
|
||||
|
||||
1. 确保已填写 API Key 和端点地址
|
||||
2. 点击模型输入框旁的 **获取模型** 按钮(下载图标)
|
||||
3. 从分组下拉菜单中选择模型
|
||||
|
||||
详细说明请参阅 [2.1 添加供应商 — 自动获取模型](./2.1-add.md#自动获取模型)。
|
||||
|
||||
## 通用配置快捷开关(Claude)
|
||||
|
||||
编辑 Claude 供应商时,JSON 编辑器上方提供常用设置的快捷开关,包括工具搜索、禁用自动更新、Teammates、高效能模式等。详见 [2.1 添加供应商 — Claude 通用配置快捷开关](./2.1-add.md#claude-通用配置快捷开关)。
|
||||
|
||||
## 修改 API Key
|
||||
|
||||
编辑供应商时,可以直接在 **API Key** 输入框中修改:
|
||||
|
||||
@@ -118,8 +118,27 @@ CC Switch 提供强大的搜索和过滤功能:
|
||||
|
||||
### 卸载效果
|
||||
|
||||
- 删除本地技能文件夹
|
||||
- 更新安装状态
|
||||
- **自动备份**:删除前,技能会被备份到 `~/.cc-switch/skill-backups/`
|
||||
- 从所有应用目录(Claude、Codex、Gemini、OpenCode)移除技能
|
||||
- 从 SSOT 目录(`~/.cc-switch/skills/`)移除技能
|
||||
- 从数据库删除技能记录
|
||||
|
||||
### 从备份恢复
|
||||
|
||||
如需恢复之前卸载的技能:
|
||||
|
||||
1. 打开 Skills 页面
|
||||
2. 点击 **从备份恢复** 按钮
|
||||
3. 从列表中选择要恢复的备份(显示技能名称和备份日期)
|
||||
4. 技能将被恢复并为当前应用启用
|
||||
|
||||
### 删除备份
|
||||
|
||||
如需删除旧的技能备份:
|
||||
|
||||
1. 在恢复对话框中,找到要删除的备份
|
||||
2. 点击备份条目旁的 **删除** 按钮
|
||||
3. 确认删除 — 此操作不可撤销
|
||||
|
||||
## 仓库管理
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# 3.4 会话管理器
|
||||
|
||||
会话管理器可让你在一处浏览、搜索和管理所有支持的 CLI 工具的对话会话。
|
||||
|
||||
## 支持的应用
|
||||
|
||||
| 应用 | 会话存储位置 |
|
||||
|------|-------------|
|
||||
| Claude Code | `~/.cache/claude/projects/*.jsonl` |
|
||||
| Codex | Codex 配置会话目录 |
|
||||
| OpenCode | `~/.local/share/opencode/`(JSON 或 SQLite) |
|
||||
| OpenClaw | `~/.openclaw/agents/<agent>/sessions/*.jsonl` |
|
||||
| Gemini CLI | `~/.cache/gemini/tmp/<project_hash>/chats/` |
|
||||
|
||||
## 打开会话管理器
|
||||
|
||||
点击主导航栏中的 **会话** 按钮。
|
||||
|
||||
> **注意**:会话按钮在所有五种应用模式下均可见。
|
||||
|
||||
## 界面布局
|
||||
|
||||
会话管理器采用 **双栏布局**:
|
||||
|
||||
- **左侧面板**:会话列表,包含搜索和过滤工具栏
|
||||
- **右侧面板**:选中会话的详情和对话历史
|
||||
|
||||
### 会话列表(左侧面板)
|
||||
|
||||
每个会话条目显示:
|
||||
- 供应商图标
|
||||
- 会话标题
|
||||
- 最后活跃时间(相对格式,如"5 分钟前")
|
||||
|
||||
### 会话详情(右侧面板)
|
||||
|
||||
选中会话后,右侧面板显示:
|
||||
- **标题**:来自会话标题、项目目录名称或会话 ID
|
||||
- **最后活跃日期/时间**:完整时间戳
|
||||
- **项目目录**:可点击复制完整路径(显示目录名,完整路径以提示框显示)
|
||||
- **恢复命令**:以等宽字体显示(如可用)
|
||||
- **对话历史**:完整消息记录
|
||||
|
||||
## 搜索与过滤
|
||||
|
||||
### 全文搜索
|
||||
|
||||
使用左侧面板顶部的搜索框,可搜索以下内容:
|
||||
- 会话 ID
|
||||
- 标题
|
||||
- 摘要
|
||||
- 项目目录
|
||||
- 源文件路径
|
||||
|
||||
搜索支持前缀匹配,实时过滤结果。按 **Esc** 清除搜索。
|
||||
|
||||
### 供应商过滤
|
||||
|
||||
点击左侧面板右上角的供应商过滤下拉菜单,按应用过滤:
|
||||
- **全部** — 显示所有供应商的会话
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **OpenCode**
|
||||
- **OpenClaw**
|
||||
- **Gemini CLI**
|
||||
|
||||
过滤可与搜索组合使用。
|
||||
|
||||
### 刷新
|
||||
|
||||
点击刷新按钮(圆形箭头图标),重新扫描所有供应商目录以发现新增或已删除的会话。
|
||||
|
||||
## 会话操作
|
||||
|
||||
### 恢复会话
|
||||
|
||||
点击选中会话上的 **恢复** 按钮(播放图标)继续对话。
|
||||
|
||||
**macOS 上**:
|
||||
- CC Switch 使用你偏好的终端启动恢复命令
|
||||
- 终端会在会话的项目目录中打开
|
||||
- 如果终端启动失败,命令会被复制到剪贴板
|
||||
|
||||
**支持的终端(macOS)**:Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty
|
||||
|
||||
**其他平台**:
|
||||
- 恢复命令会被复制到剪贴板
|
||||
- 在终端中粘贴即可恢复会话
|
||||
|
||||
> 如果会话没有可用的恢复命令,恢复按钮将被禁用。
|
||||
|
||||
### 删除会话
|
||||
|
||||
点击 **删除** 按钮(垃圾桶图标)永久删除会话文件。删除前会显示确认对话框。
|
||||
|
||||
> 没有本地源路径的会话(如不可变会话)无法删除。
|
||||
|
||||
### 批量操作
|
||||
|
||||
批量管理多个会话:
|
||||
|
||||
1. 点击左侧面板工具栏中的 **批量模式** 按钮(复选框图标)
|
||||
2. 使用出现的复选框选择会话
|
||||
3. 使用 **全选** 选择所有过滤结果,或使用 **清除** 取消选择
|
||||
4. 点击 **批量删除**(红色垃圾桶图标)删除所有选中的会话
|
||||
|
||||
删除前会显示确认对话框并展示数量。结果会报告成功删除的数量和失败的数量。
|
||||
|
||||
## 对话历史
|
||||
|
||||
### 消息显示
|
||||
|
||||
消息按角色以颜色区分:
|
||||
- **用户** 消息:绿色,左对齐
|
||||
- **AI**(助手)消息:蓝色,右对齐
|
||||
- **系统** 消息:琥珀色
|
||||
- **工具** 消息:紫色
|
||||
|
||||
### 目录导航
|
||||
|
||||
对于较长的对话,提供目录导航功能:
|
||||
- **桌面端(XL+ 屏幕)**:右侧边栏显示用户消息预览
|
||||
- **较小屏幕**:右下角浮动按钮(列表图标),点击打开对话框
|
||||
|
||||
点击任意条目可滚动到对应消息,消息会短暂高亮显示。
|
||||
|
||||
## 提示
|
||||
|
||||
- 会话按最后活跃时间排序(最新在前)
|
||||
- 会话数量徽章会随搜索和过滤实时更新
|
||||
- OpenCode 会话可能来自 JSON 文件和 SQLite 数据库 — 重复项会自动去重
|
||||
@@ -0,0 +1,85 @@
|
||||
# 3.5 工作区文件与每日记忆
|
||||
|
||||
## 概述
|
||||
|
||||
工作区面板为 **OpenClaw** 提供文件管理和每日记忆功能。你可以编辑工作区配置文件并维护每日记忆日志。
|
||||
|
||||
> 此功能为 OpenClaw 专属。当 OpenClaw 为当前选中的应用时,导航栏中会显示工作区按钮。
|
||||
|
||||
## 工作区文件
|
||||
|
||||
### 文件位置
|
||||
|
||||
所有工作区文件存储在 `~/.openclaw/workspace/`。
|
||||
|
||||
点击面板顶部的目录路径可在文件管理器中打开。
|
||||
|
||||
### 可用文件
|
||||
|
||||
CC Switch 管理 9 个工作区文件,每个文件有特定用途:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| **AGENTS.md** | Agent 定义与配置 |
|
||||
| **SOUL.md** | 系统灵魂/性格设置 |
|
||||
| **USER.md** | 用户资料信息 |
|
||||
| **IDENTITY.md** | 身份与角色定义 |
|
||||
| **TOOLS.md** | 可用工具配置 |
|
||||
| **MEMORY.md** | 系统记忆 |
|
||||
| **HEARTBEAT.md** | 心跳配置 |
|
||||
| **BOOTSTRAP.md** | 引导序列 |
|
||||
| **BOOT.md** | 启动配置 |
|
||||
|
||||
### 文件状态
|
||||
|
||||
每个文件显示状态指示器:
|
||||
- **绿色对勾**:文件已存在
|
||||
- **空心圆圈**:文件尚未创建(首次保存时创建)
|
||||
|
||||
### 编辑文件
|
||||
|
||||
1. 点击任意文件卡片打开 Markdown 编辑器
|
||||
2. 编辑内容
|
||||
3. 点击 **保存** 将更改写入磁盘
|
||||
|
||||
如果文件尚不存在,将在首次保存时创建。
|
||||
|
||||
## 每日记忆
|
||||
|
||||
每日记忆功能提供按日期组织的日志系统,存储在 `~/.openclaw/workspace/memory/`。
|
||||
|
||||
### 访问每日记忆
|
||||
|
||||
点击工作区文件网格中的 **每日记忆** 卡片,打开记忆面板。
|
||||
|
||||
### 文件列表
|
||||
|
||||
面板显示所有每日记忆文件,按日期排序(最新在前)。每个条目显示:
|
||||
- **日期**(从文件名格式化,如 `2026-04-01.md`)
|
||||
- **文件大小**
|
||||
- **预览**(内容的前 2 行)
|
||||
|
||||
### 创建今日笔记
|
||||
|
||||
点击 **创建今日** 按钮:
|
||||
- 打开以今日日期命名的新笔记(`YYYY-MM-DD.md`)
|
||||
- 如果今日笔记已存在,则打开进行编辑
|
||||
- 文件仅在点击保存后才会持久化
|
||||
|
||||
### 搜索
|
||||
|
||||
搜索所有每日记忆文件:
|
||||
|
||||
1. 按 **Cmd/Ctrl+F** 或点击搜索图标
|
||||
2. 输入搜索词
|
||||
3. 结果显示匹配的文件,包含:
|
||||
- 每个文件的匹配数量
|
||||
- 匹配行的片段预览
|
||||
- 文件日期和大小
|
||||
|
||||
按 **Esc** 关闭搜索。
|
||||
|
||||
### 编辑与删除
|
||||
|
||||
- **编辑**:点击文件条目在 Markdown 编辑器中打开
|
||||
- **删除**:将鼠标悬停在文件条目上,点击删除图标。会显示确认对话框 — 删除操作不可撤销。
|
||||
@@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1"
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
|
||||
```
|
||||
|
||||
## API 格式转换
|
||||
|
||||
代理支持为配置了非 Anthropic 格式的供应商自动进行 API 格式转换。这使你可以将仅支持 OpenAI 兼容 API 的供应商用于 Claude Code。
|
||||
|
||||
| 供应商 API 格式 | 代理行为 |
|
||||
|-----------------|----------|
|
||||
| **Anthropic Messages** | 透传(不转换) |
|
||||
| **OpenAI Chat Completions** | 将 Anthropic 请求转换为 OpenAI Chat 格式,响应反向转换 |
|
||||
| **OpenAI Responses API** | 将 Anthropic 请求转换为 OpenAI Responses 格式,响应反向转换 |
|
||||
|
||||
API 格式在添加或编辑 Claude 供应商时于[高级选项](../2-providers/2.1-add.md#api-格式仅-claude)中按供应商配置。
|
||||
|
||||
> **注意**:格式转换需要代理运行并启用应用接管。转换同时支持流式和非流式请求。
|
||||
|
||||
## 停止代理
|
||||
|
||||
### 方式一:主界面开关
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
|
||||
```
|
||||
~/.cc-switch/
|
||||
├── cc-switch.db # SQLite 数据库
|
||||
├── cc-switch.db # SQLite 数据库(SSOT)
|
||||
├── settings.json # 设备级设置
|
||||
└── backups/ # 自动备份
|
||||
├── backup-20251230-120000.json
|
||||
├── backup-20251229-180000.json
|
||||
└── ...
|
||||
├── skills/ # 技能 SSOT 目录
|
||||
├── skill-backups/ # 技能备份(卸载时创建)
|
||||
└── db_backup_*.db # 数据库备份
|
||||
```
|
||||
|
||||
### 数据库内容
|
||||
|
||||
@@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage
|
||||
2. 手动下载最新版本安装
|
||||
3. 如使用 Homebrew:`brew upgrade --cask cc-switch`
|
||||
|
||||
## 轻量模式
|
||||
|
||||
### 如何进入轻量模式?
|
||||
|
||||
从系统托盘菜单切换"轻量模式"。主窗口关闭,CC Switch 仅作为托盘应用运行。再次切换或点击"打开主界面"即可退出。
|
||||
|
||||
### 轻量模式下应用占用更少内存?
|
||||
|
||||
是的。轻量模式会销毁主窗口及其 Web 视图,显著减少内存占用,同时保留托盘菜单功能。
|
||||
|
||||
## 获取帮助
|
||||
|
||||
### 提交 Issue
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
├── 3. 扩展功能
|
||||
│ ├── 3.1 MCP 服务器管理
|
||||
│ ├── 3.2 Prompts 提示词管理
|
||||
│ └── 3.3 Skills 技能管理
|
||||
│ ├── 3.3 Skills 技能管理
|
||||
│ ├── 3.4 会话管理器
|
||||
│ └── 3.5 工作区文件与每日记忆
|
||||
│
|
||||
├── 4. 代理与高可用
|
||||
│ ├── 4.1 代理服务
|
||||
@@ -69,6 +71,8 @@
|
||||
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP 协议、添加服务器、应用绑定 |
|
||||
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | 创建预设、激活切换、智能回填 |
|
||||
| [3.3-skills.md](./3-extensions/3.3-skills.md) | 发现技能、安装卸载、仓库管理 |
|
||||
| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | 会话浏览、搜索过滤、恢复与删除 |
|
||||
| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | OpenClaw 工作区文件、每日记忆 |
|
||||
|
||||
### 4. 代理与高可用
|
||||
|
||||
@@ -99,9 +103,9 @@
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 文档版本:v3.12.0
|
||||
- 最后更新:2026-03-09
|
||||
- 适用于 CC Switch v3.12.0+
|
||||
- 文档版本:v3.12.3
|
||||
- 最后更新:2026-04-04
|
||||
- 适用于 CC Switch v3.12.3+
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ If you prefer minimal permissions (e.g., for Flathub submission or security conc
|
||||
- --filesystem=~/.claude.json
|
||||
- --filesystem=~/.codex:create
|
||||
- --filesystem=~/.gemini:create
|
||||
- --filesystem=~/.config/opencode:create
|
||||
- --filesystem=~/.openclaw:create
|
||||
```
|
||||
|
||||
Note: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).
|
||||
|
||||
Generated
+137
@@ -107,6 +107,26 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -718,6 +738,7 @@ name = "cc-switch"
|
||||
version = "3.12.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"async-stream",
|
||||
"auto-launch",
|
||||
"axum",
|
||||
@@ -843,6 +864,15 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
@@ -1445,6 +1475,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -1484,6 +1520,26 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
|
||||
dependencies = [
|
||||
"fax_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fax_derive"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1849,6 +1905,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
@@ -2067,6 +2133,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -2457,6 +2534,7 @@ dependencies = [
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3088,6 +3166,7 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
@@ -3894,6 +3973,12 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -5766,6 +5851,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -6660,6 +6759,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -7397,6 +7502,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -7682,6 +7804,21 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
|
||||
@@ -39,6 +39,7 @@ dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
arboard = "3.6"
|
||||
flate2 = "1"
|
||||
brotli = "7"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::services::subscription::SubscriptionQuota;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#[tauri::command]
|
||||
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
||||
crate::lightweight::enter_lightweight_mode(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
||||
crate::lightweight::exit_lightweight_mode(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_lightweight_mode() -> bool {
|
||||
crate::lightweight::is_lightweight_mode()
|
||||
}
|
||||
+190
-14
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
@@ -34,6 +34,22 @@ pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String>
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(text: String) -> Result<bool, String> {
|
||||
// Use spawn_blocking to avoid blocking the async runtime
|
||||
// Clipboard access can block on some platforms and may have thread/loop constraints
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut clipboard =
|
||||
arboard::Clipboard::new().map_err(|e| format!("访问系统剪贴板失败: {e}"))?;
|
||||
clipboard
|
||||
.set_text(text)
|
||||
.map_err(|e| format!("写入系统剪贴板失败: {e}"))?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("剪贴板任务执行失败: {e}"))?
|
||||
}
|
||||
|
||||
/// 检查更新
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
||||
@@ -720,8 +736,10 @@ pub async fn open_provider_terminal(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
cwd: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
let launch_cwd = resolve_launch_cwd(cwd)?;
|
||||
|
||||
// 获取提供商配置
|
||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||
@@ -736,7 +754,8 @@ pub async fn open_provider_terminal(
|
||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||
|
||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
|
||||
.map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -791,11 +810,49 @@ fn extract_env_vars_from_config(
|
||||
env_vars
|
||||
}
|
||||
|
||||
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
|
||||
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if raw_path.contains('\n') || raw_path.contains('\r') {
|
||||
return Err("目录路径包含非法换行符".to_string());
|
||||
}
|
||||
|
||||
let path = Path::new(&raw_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("目录不存在: {raw_path}"));
|
||||
}
|
||||
|
||||
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
|
||||
}
|
||||
|
||||
// Strip Windows extended-length prefix that canonicalize produces,
|
||||
// as it can break batch scripts and other shell commands.
|
||||
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
|
||||
#[cfg(target_os = "windows")]
|
||||
let resolved = {
|
||||
let s = resolved.to_string_lossy();
|
||||
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
|
||||
PathBuf::from(format!(r"\\{unc}"))
|
||||
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
resolved
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(resolved))
|
||||
}
|
||||
|
||||
/// 创建临时配置文件并启动 claude 终端
|
||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||
fn launch_terminal_with_env(
|
||||
env_vars: Vec<(String, String)>,
|
||||
provider_id: &str,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let config_file = temp_dir.join(format!(
|
||||
@@ -809,19 +866,19 @@ fn launch_terminal_with_env(
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file)?;
|
||||
launch_macos_terminal(&config_file, cwd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file)?;
|
||||
launch_linux_terminal(&config_file, cwd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -851,7 +908,7 @@ fn write_claude_config(
|
||||
|
||||
/// macOS: 根据用户首选终端启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
@@ -860,18 +917,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
let cd_command = build_shell_cd_command(cwd);
|
||||
|
||||
// Write the shell script to a temp file
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
{cd_command}
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display()
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -1007,7 +1067,7 @@ fn launch_macos_open_app(
|
||||
|
||||
/// Linux: 根据用户首选终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1029,17 +1089,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
let cd_command = build_shell_cd_command(cwd);
|
||||
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
{cd_command}
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display()
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -1053,21 +1116,21 @@ exec bash --norc --noprofile
|
||||
let pref_args = default_terminals
|
||||
.iter()
|
||||
.find(|(name, _)| *name == pref.as_str())
|
||||
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
|
||||
.map(|(_, args)| args.to_vec())
|
||||
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
|
||||
|
||||
let mut list = vec![(pref.as_str(), pref_args)];
|
||||
// Add remaining terminals as fallbacks
|
||||
for (name, args) in &default_terminals {
|
||||
if *name != pref.as_str() {
|
||||
list.push((*name, args.iter().map(|s| *s).collect()));
|
||||
list.push((*name, args.to_vec()));
|
||||
}
|
||||
}
|
||||
list
|
||||
} else {
|
||||
default_terminals
|
||||
.iter()
|
||||
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
|
||||
.map(|(name, args)| (*name, args.to_vec()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -1118,22 +1181,28 @@ fn which_command(cmd: &str) -> bool {
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
|
||||
let cwd_command = build_windows_cwd_command(cwd);
|
||||
|
||||
let content = format!(
|
||||
"@echo off
|
||||
{cwd_command}
|
||||
echo Using provider-specific claude config:
|
||||
echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
",
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
config_path_for_batch,
|
||||
config_path_for_batch,
|
||||
config_path_for_batch,
|
||||
cwd_command = cwd_command,
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
@@ -1164,6 +1233,55 @@ del \"%~f0\" >nul 2>&1
|
||||
result
|
||||
}
|
||||
|
||||
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
|
||||
cwd.map(|dir| {
|
||||
format!(
|
||||
"cd {} || exit 1\n",
|
||||
shell_single_quote(&dir.to_string_lossy())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn is_windows_unc_path(path: &str) -> bool {
|
||||
path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn build_windows_cwd_command_str(path: &str) -> String {
|
||||
let escaped = escape_windows_batch_value(path);
|
||||
|
||||
if is_windows_unc_path(path) {
|
||||
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
|
||||
format!("pushd \"{escaped}\" || exit /b 1\r\n")
|
||||
} else {
|
||||
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
|
||||
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn escape_windows_batch_value(value: &str) -> String {
|
||||
value
|
||||
.replace('^', "^^")
|
||||
.replace('%', "%%")
|
||||
.replace('&', "^&")
|
||||
.replace('|', "^|")
|
||||
.replace('<', "^<")
|
||||
.replace('>', "^>")
|
||||
.replace('(', "^(")
|
||||
.replace(')', "^)")
|
||||
}
|
||||
/// Windows: Run a start command with common error handling
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||||
@@ -1338,4 +1456,62 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_cwd_accepts_existing_directory() {
|
||||
let resolved =
|
||||
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
|
||||
.expect("temp dir should resolve")
|
||||
.expect("temp dir should be present");
|
||||
|
||||
assert!(resolved.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_cwd_rejects_missing_directory() {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos();
|
||||
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
|
||||
|
||||
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
|
||||
.expect_err("missing directory should fail");
|
||||
|
||||
assert!(error.contains("目录不存在"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
|
||||
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
|
||||
|
||||
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
|
||||
let command = build_windows_cwd_command_str(r"C:\work\repo");
|
||||
|
||||
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
|
||||
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
|
||||
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod auth;
|
||||
mod coding_plan;
|
||||
mod config;
|
||||
mod copilot;
|
||||
mod deeplink;
|
||||
@@ -10,6 +11,7 @@ mod global_proxy;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod model_fetch;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod plugin;
|
||||
@@ -20,13 +22,16 @@ mod session_manager;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod subscription;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use auth::*;
|
||||
pub use coding_plan::*;
|
||||
pub use config::*;
|
||||
pub use copilot::*;
|
||||
pub use deeplink::*;
|
||||
@@ -36,6 +41,7 @@ pub use global_proxy::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use model_fetch::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub use plugin::*;
|
||||
@@ -46,7 +52,9 @@ pub use session_manager::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use subscription::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! 模型列表获取命令
|
||||
//!
|
||||
//! 提供 Tauri 命令,供前端在供应商表单中获取可用模型列表。
|
||||
|
||||
use crate::services::model_fetch::{self, FetchedModel};
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn fetch_models_for_config(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
is_full_url: Option<bool>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use std::str::FromStr;
|
||||
|
||||
// 常量定义
|
||||
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
|
||||
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -36,9 +37,11 @@ pub fn add_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider: Provider,
|
||||
#[allow(non_snake_case)] addToLive: Option<bool>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||
ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -46,9 +49,11 @@ pub fn update_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
provider: Provider,
|
||||
#[allow(non_snake_case)] originalId: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
|
||||
ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -154,29 +159,25 @@ pub async fn queryProviderUsage(
|
||||
) -> 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 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 usage_script = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref());
|
||||
let template_type = usage_script
|
||||
.and_then(|s| s.template_type.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
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
|
||||
// ── GitHub Copilot 专用路径 ──
|
||||
if template_type == TEMPLATE_TYPE_GITHUB_COPILOT {
|
||||
let copilot_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
|
||||
@@ -207,6 +208,67 @@ pub async fn queryProviderUsage(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Coding Plan 专用路径 ──
|
||||
if template_type == TEMPLATE_TYPE_TOKEN_PLAN {
|
||||
// 从供应商配置中提取 API Key 和 Base URL
|
||||
let settings_config = provider
|
||||
.map(|p| &p.settings_config)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let env = settings_config.get("env");
|
||||
let base_url = env
|
||||
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let api_key = env
|
||||
.and_then(|e| {
|
||||
e.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| e.get("ANTHROPIC_API_KEY"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
|
||||
// 将 SubscriptionQuota 转换为 UsageResult
|
||||
if !quota.success {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: quota.error,
|
||||
});
|
||||
}
|
||||
|
||||
let data: Vec<crate::provider::UsageData> = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.map(|tier| {
|
||||
let total = 100.0;
|
||||
let used = tier.utilization;
|
||||
let remaining = total - used;
|
||||
crate::provider::UsageData {
|
||||
plan_name: Some(tier.name.clone()),
|
||||
remaining: Some(remaining),
|
||||
total: Some(total),
|
||||
used: Some(used),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: tier.resets_at.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: true,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 通用 JS 脚本路径 ──
|
||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
@@ -74,3 +74,12 @@ pub async fn delete_session(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_sessions(
|
||||
items: Vec<session_manager::DeleteSessionRequest>,
|
||||
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete sessions: {e}"))
|
||||
}
|
||||
|
||||
@@ -247,6 +247,30 @@ pub async fn set_optimizer_config(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取 Copilot 优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_copilot_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::CopilotOptimizerConfig, String> {
|
||||
state
|
||||
.db
|
||||
.get_copilot_optimizer_config()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置 Copilot 优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_copilot_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::CopilotOptimizerConfig,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.set_copilot_optimizer_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取日志配置
|
||||
#[tauri::command]
|
||||
pub async fn get_log_config(
|
||||
|
||||
@@ -26,6 +26,7 @@ pub async fn stream_check_provider(
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
provider,
|
||||
@@ -39,6 +40,7 @@ pub async fn stream_check_provider(
|
||||
provider,
|
||||
&config,
|
||||
auth_override,
|
||||
base_url_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await?;
|
||||
@@ -87,6 +89,8 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||
let base_url_override =
|
||||
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
&provider,
|
||||
@@ -108,6 +112,7 @@ pub async fn stream_check_all_providers(
|
||||
&provider,
|
||||
&config,
|
||||
auth_override,
|
||||
base_url_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
@@ -151,17 +156,7 @@ 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);
|
||||
let is_copilot = is_copilot_provider(provider);
|
||||
|
||||
if !is_copilot {
|
||||
return Ok(None);
|
||||
@@ -171,7 +166,7 @@ async fn resolve_copilot_auth_override(
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.github_account_id.clone());
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let token = match account_id.as_deref() {
|
||||
Some(id) => auth_manager
|
||||
@@ -190,6 +185,49 @@ async fn resolve_copilot_auth_override(
|
||||
)))
|
||||
}
|
||||
|
||||
async fn resolve_copilot_base_url_override(
|
||||
provider: &crate::provider::Provider,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
let is_copilot = is_copilot_provider(provider);
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_copilot || is_full_url {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let endpoint = match account_id.as_deref() {
|
||||
Some(id) => auth_manager.get_api_endpoint(id).await,
|
||||
None => auth_manager.get_default_api_endpoint().await,
|
||||
};
|
||||
|
||||
Ok(Some(endpoint))
|
||||
}
|
||||
|
||||
fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
|
||||
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)
|
||||
}
|
||||
|
||||
async fn resolve_claude_api_format_override(
|
||||
app_type: &AppType,
|
||||
provider: &crate::provider::Provider,
|
||||
@@ -237,3 +275,80 @@ async fn resolve_claude_api_format_override(
|
||||
|
||||
Ok(Some(api_format.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_copilot_provider;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn copilot_provider_detection_accepts_provider_type_or_base_url() {
|
||||
let typed_provider = Provider {
|
||||
id: "p1".to_string(),
|
||||
name: "typed".to_string(),
|
||||
settings_config: json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
assert!(is_copilot_provider(&typed_provider));
|
||||
|
||||
let url_provider = Provider {
|
||||
id: "p2".to_string(),
|
||||
name: "url".to_string(),
|
||||
settings_config: json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
assert!(is_copilot_provider(&url_provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copilot_full_url_metadata_is_available_for_override_guard() {
|
||||
let provider = Provider {
|
||||
id: "p3".to_string(),
|
||||
name: "relay".to_string(),
|
||||
settings_config: json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
is_full_url: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
assert!(is_copilot_provider(&provider));
|
||||
assert_eq!(
|
||||
provider.meta.as_ref().and_then(|meta| meta.is_full_url),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::services::subscription::SubscriptionQuota;
|
||||
|
||||
/// 查询官方订阅额度
|
||||
///
|
||||
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
|
||||
/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。
|
||||
#[tauri::command]
|
||||
pub async fn get_subscription_quota(tool: String) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::subscription::get_subscription_quota(&tool).await
|
||||
}
|
||||
@@ -270,6 +270,31 @@ impl Database {
|
||||
self.set_setting("optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- Copilot 优化器配置 ---
|
||||
|
||||
/// 获取 Copilot 优化器配置
|
||||
///
|
||||
/// 返回配置,如果不存在则返回默认值(默认开启)
|
||||
pub fn get_copilot_optimizer_config(
|
||||
&self,
|
||||
) -> Result<crate::proxy::types::CopilotOptimizerConfig, AppError> {
|
||||
match self.get_setting("copilot_optimizer_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析 Copilot 优化器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::CopilotOptimizerConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 Copilot 优化器配置
|
||||
pub fn set_copilot_optimizer_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::CopilotOptimizerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化 Copilot 优化器配置失败: {e}")))?;
|
||||
self.set_setting("copilot_optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- 日志配置 ---
|
||||
|
||||
/// 获取日志配置
|
||||
|
||||
@@ -110,7 +110,7 @@ pub fn import_provider_from_deeplink(
|
||||
let provider_id = provider.id.clone();
|
||||
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider)?;
|
||||
ProviderService::add(state, app_type.clone(), provider, true)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
@@ -229,6 +229,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
coding_plan_provider: None,
|
||||
};
|
||||
|
||||
Ok(Some(ProviderMeta {
|
||||
|
||||
@@ -12,6 +12,7 @@ mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
mod mcp;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
@@ -204,6 +205,12 @@ pub fn run() {
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
}
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||
let mut found_deeplink = false;
|
||||
for arg in &args {
|
||||
@@ -615,6 +622,12 @@ pub fn run() {
|
||||
let urls = event.urls();
|
||||
log::info!("Received {} URL(s)", urls.len());
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
@@ -854,11 +867,14 @@ pub fn run() {
|
||||
commands::set_rectifier_config,
|
||||
commands::get_optimizer_config,
|
||||
commands::set_optimizer_config,
|
||||
commands::get_copilot_optimizer_config,
|
||||
commands::set_copilot_optimizer_config,
|
||||
commands::get_log_config,
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::get_claude_plugin_status,
|
||||
commands::read_claude_plugin_config,
|
||||
commands::apply_claude_plugin_config,
|
||||
@@ -874,6 +890,9 @@ pub fn run() {
|
||||
// usage query
|
||||
commands::queryProviderUsage,
|
||||
commands::testUsageScript,
|
||||
// subscription quota
|
||||
commands::get_subscription_quota,
|
||||
commands::get_coding_plan_quota,
|
||||
// New MCP via config.json (SSOT)
|
||||
commands::get_mcp_config,
|
||||
commands::upsert_mcp_server_in_config,
|
||||
@@ -892,6 +911,8 @@ pub fn run() {
|
||||
commands::enable_prompt,
|
||||
commands::import_prompt_from_file,
|
||||
commands::get_current_prompt_file_content,
|
||||
// model list fetch (OpenAI-compatible /v1/models)
|
||||
commands::fetch_models_for_config,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
commands::test_api_endpoints,
|
||||
commands::get_custom_endpoints,
|
||||
@@ -1008,6 +1029,7 @@ pub fn run() {
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::delete_session,
|
||||
commands::delete_sessions,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
@@ -1085,6 +1107,10 @@ pub fn run() {
|
||||
commands::delete_daily_memory_file,
|
||||
commands::search_daily_memory_files,
|
||||
commands::open_workspace_directory,
|
||||
// lightweight mode (for testing or low-resource environments)
|
||||
commands::enter_lightweight_mode,
|
||||
commands::exit_lightweight_mode,
|
||||
commands::is_lightweight_mode,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
@@ -1135,6 +1161,10 @@ pub fn run() {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
tray::apply_tray_policy(app_handle, true);
|
||||
} else if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||
@@ -1144,6 +1174,13 @@ pub fn run() {
|
||||
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||
|
||||
if url_str.starts_with("ccswitch://") {
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
|
||||
{
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
||||
match crate::deeplink::parse_deeplink_url(&url_str) {
|
||||
Ok(request) => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_skip_taskbar(true);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, false);
|
||||
}
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window
|
||||
.destroy()
|
||||
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
|
||||
}
|
||||
// else: already in lightweight mode or window not found, just set the flag
|
||||
|
||||
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("进入轻量模式");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::WebviewWindowBuilder;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, true);
|
||||
}
|
||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("退出轻量模式");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let window_config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.label == "main")
|
||||
.ok_or("主窗口配置未找到")?;
|
||||
|
||||
WebviewWindowBuilder::from_config(app, window_config)
|
||||
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
|
||||
.visible(true)
|
||||
.build()
|
||||
.map_err(|e| format!("创建主窗口失败: {e}"))?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, true);
|
||||
}
|
||||
|
||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("退出轻量模式");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_lightweight_mode() -> bool {
|
||||
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use crate::error::AppError;
|
||||
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
|
||||
use chrono::Local;
|
||||
use indexmap::IndexMap;
|
||||
use json_five::parser::{FormatConfiguration, TrailingComma};
|
||||
use json_five::rt::parser::{
|
||||
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
|
||||
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
|
||||
@@ -38,9 +37,7 @@ pub fn get_openclaw_dir() -> PathBuf {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".openclaw"))
|
||||
.unwrap_or_else(|| PathBuf::from(".openclaw"))
|
||||
crate::config::get_home_dir().join(".openclaw")
|
||||
}
|
||||
|
||||
/// 获取 OpenClaw 配置文件路径
|
||||
@@ -490,11 +487,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
|
||||
}
|
||||
|
||||
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
|
||||
let source = json_five::to_string_formatted(
|
||||
value,
|
||||
FormatConfiguration::with_indent(2, TrailingComma::NONE),
|
||||
)
|
||||
.map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?;
|
||||
// `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays.
|
||||
// Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and
|
||||
// can still be parsed back into the round-trip AST we use for insertion.
|
||||
let source = serde_json::to_string_pretty(value)
|
||||
.map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?;
|
||||
|
||||
let adjusted = reindent_json5_block(&source, parent_indent);
|
||||
let text = rt_from_str(&adjusted).map_err(|e| {
|
||||
@@ -1051,4 +1048,37 @@ mod tests {
|
||||
assert!(err.to_string().contains("OpenClaw config changed on disk"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_provider_writes_empty_providers_without_panic() {
|
||||
let source = r#"{
|
||||
models: {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
'1-copy': {
|
||||
api: 'anthropic-messages',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
with_test_paths(source, |_| {
|
||||
let outcome = remove_provider("1-copy").unwrap();
|
||||
assert!(outcome.backup_path.is_some());
|
||||
|
||||
let config = read_openclaw_config().unwrap();
|
||||
let providers = config
|
||||
.get("models")
|
||||
.and_then(|models| models.get("providers"))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(providers.is_empty());
|
||||
|
||||
let written = fs::read_to_string(get_openclaw_config_path()).unwrap();
|
||||
assert!(written.contains("\"providers\": {}"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,40 @@ use indexmap::IndexMap;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
|
||||
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
|
||||
|
||||
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
|
||||
plugin_name == prefix
|
||||
|| plugin_name
|
||||
.strip_prefix(prefix)
|
||||
.map(|suffix| suffix.starts_with('@'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
|
||||
prefixes
|
||||
.iter()
|
||||
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
|
||||
}
|
||||
|
||||
fn canonicalize_plugin_name(plugin_name: &str) -> String {
|
||||
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
|
||||
if suffix.is_empty() || suffix.starts_with('@') {
|
||||
return format!("oh-my-openagent{suffix}");
|
||||
}
|
||||
}
|
||||
plugin_name.to_string()
|
||||
}
|
||||
|
||||
pub fn get_opencode_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_opencode_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config").join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
crate::config::get_home_dir()
|
||||
.join(".config")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
pub fn get_opencode_config_path() -> PathBuf {
|
||||
@@ -140,58 +166,56 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
|
||||
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
|
||||
|
||||
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
||||
|
||||
match plugins {
|
||||
Some(arr) => {
|
||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||
if plugin_name.starts_with("oh-my-opencode")
|
||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||
{
|
||||
// Adding standard OMO -> remove all Slim variants
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
{
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
|
||||
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||
let already_exists = arr
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
|
||||
if !already_exists {
|
||||
arr.push(Value::String(plugin_name.to_string()));
|
||||
arr.push(Value::String(normalized_plugin_name));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
config["plugin"] = json!([plugin_name]);
|
||||
config["plugin"] = json!([normalized_plugin_name]);
|
||||
}
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
|
||||
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
if !s.starts_with(prefix) {
|
||||
return true; // Keep: doesn't match prefix at all
|
||||
}
|
||||
let rest = &s[prefix.len()..];
|
||||
rest.starts_with('-')
|
||||
})
|
||||
.map(|s| !matches_any_plugin_prefix(s, prefixes))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
|
||||
@@ -106,6 +106,10 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "autoQueryInterval")]
|
||||
pub auto_query_interval: Option<u64>,
|
||||
/// Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "codingPlanProvider")]
|
||||
pub coding_plan_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
@@ -283,6 +287,10 @@ pub struct ProviderMeta {
|
||||
/// 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>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||
pub live_config_managed: Option<bool>,
|
||||
/// 供应商类型标识(用于特殊供应商检测)
|
||||
/// - "github_copilot": GitHub Copilot 供应商
|
||||
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
//! Copilot 请求优化器
|
||||
//!
|
||||
//! 解决 GitHub Copilot 代理消耗量异常问题(Issue #1813)。
|
||||
//!
|
||||
//! Copilot 使用 `x-initiator` 请求头区分「用户发起」和「agent 续写」:
|
||||
//! - `user`:计为一次 premium interaction(扣额度)
|
||||
//! - `agent`:视为上一次交互的延续(不额外扣费)
|
||||
//!
|
||||
//! 参考实现: https://github.com/caozhiyuan/copilot-api
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 请求分类结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CopilotClassification {
|
||||
/// "user" 或 "agent" — 映射到 x-initiator 请求头
|
||||
pub initiator: &'static str,
|
||||
/// 是否为 warmup/探针请求(可降级到小模型)
|
||||
pub is_warmup: bool,
|
||||
/// 是否为上下文压缩请求
|
||||
pub is_compact: bool,
|
||||
}
|
||||
|
||||
/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。
|
||||
///
|
||||
/// 分类算法(只检查最后一条消息,与参考实现 caozhiyuan/copilot-api 对齐):
|
||||
/// 1. 无消息 → "user"(安全默认,首次请求)
|
||||
/// 2. 最后消息 role=user:
|
||||
/// - content 中存在非 tool_result 类型 block → "user"
|
||||
/// - content 全部是 tool_result → "agent"
|
||||
/// - 匹配 compact 模式 → "agent"
|
||||
/// 3. 最后消息 role 非 user → "user"(安全默认)
|
||||
///
|
||||
/// Warmup 检测(与参考实现对齐):
|
||||
/// - 请求头中有 `anthropic-beta` + 无 tools + 非 compact → warmup
|
||||
///
|
||||
/// `compact_detection`:是否启用 compact 检测。为 false 时跳过,
|
||||
/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。
|
||||
pub fn classify_request(
|
||||
body: &Value,
|
||||
has_anthropic_beta: bool,
|
||||
compact_detection: bool,
|
||||
) -> CopilotClassification {
|
||||
let is_compact = compact_detection && is_compact_request(body);
|
||||
|
||||
let messages = match body.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(msgs) if !msgs.is_empty() => msgs,
|
||||
_ => {
|
||||
return CopilotClassification {
|
||||
initiator: "user",
|
||||
is_warmup: is_warmup_request(body, has_anthropic_beta, false),
|
||||
is_compact: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let last_msg = &messages[messages.len() - 1];
|
||||
let role = last_msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
|
||||
// 只有 role=user 的消息需要细分
|
||||
if role != "user" {
|
||||
return CopilotClassification {
|
||||
initiator: "user",
|
||||
is_warmup: false,
|
||||
is_compact,
|
||||
};
|
||||
}
|
||||
|
||||
// 参考实现的判定逻辑(Messages API 路径):
|
||||
// 如果 content 是数组,检查是否有非 tool_result 的 block
|
||||
// 有 → "user",全是 tool_result → "agent"
|
||||
// 如果 content 是字符串 → "user"
|
||||
let is_user_initiated = match last_msg.get("content") {
|
||||
Some(content) if content.is_array() => {
|
||||
let blocks = content.as_array().unwrap();
|
||||
// 存在非 tool_result block → 用户发起
|
||||
blocks
|
||||
.iter()
|
||||
.any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
|
||||
}
|
||||
Some(content) if content.is_string() => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let initiator = if !is_user_initiated || is_compact {
|
||||
"agent"
|
||||
} else {
|
||||
"user"
|
||||
};
|
||||
|
||||
CopilotClassification {
|
||||
initiator,
|
||||
is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact),
|
||||
is_compact,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测是否为 warmup/探针请求(适合降级到小模型)。
|
||||
///
|
||||
/// 与参考实现对齐,三个条件同时满足:
|
||||
/// 1. 请求头有 `anthropic-beta`(Claude Code warmup 探针的标志)
|
||||
/// 2. 无 tools 定义
|
||||
/// 3. 非 compact 请求
|
||||
fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -> bool {
|
||||
if !has_anthropic_beta || is_compact {
|
||||
return false;
|
||||
}
|
||||
// 无工具定义
|
||||
!matches!(body.get("tools"), Some(tools) if tools.is_array() && !tools.as_array().unwrap().is_empty())
|
||||
}
|
||||
|
||||
/// 检测是否为 Claude Code 上下文压缩/compact 请求。
|
||||
///
|
||||
/// 只匹配 Claude Code **内部生成**的机器特征,不匹配用户可能手动输入的通用短语,
|
||||
/// 避免将真实用户请求误标为 agent。
|
||||
///
|
||||
/// 强特征来源:
|
||||
/// 1. system prompt — Claude Code compact 模式会设置专用 system prompt,用户无法手动设置
|
||||
/// 2. "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools." — 机器指令
|
||||
/// 3. 同时包含 "Pending Tasks:" 和 "Current Work:" — Claude Code compact 的结构标记
|
||||
fn is_compact_request(body: &Value) -> bool {
|
||||
// 信号 1: system prompt 以 Claude Code compact 专用前缀开头
|
||||
// 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号
|
||||
if let Some(system) = body.get("system") {
|
||||
let system_text = if let Some(s) = system.as_str() {
|
||||
s.to_string()
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if system_text
|
||||
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 信号 2 & 3: 检查最后一条用户消息中的机器生成特征
|
||||
let messages = match body.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(msgs) => msgs,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if let Some(last_msg) = messages.last() {
|
||||
if last_msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text = extract_text_from_message(last_msg);
|
||||
|
||||
// 信号 2: Claude Code compact 的机器指令(大小写敏感,精确匹配)
|
||||
if text.contains("CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 信号 3: Claude Code compact 的结构标记(两个同时出现才算)
|
||||
if text.contains("Pending Tasks:") && text.contains("Current Work:") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 合并用户消息中的 tool_result 和 text block。
|
||||
///
|
||||
/// 与参考实现 `mergeToolResultForClaude` 对齐:
|
||||
///
|
||||
/// **消息内部合并**(核心):在单条 user 消息内,将 text block 吸收进 tool_result block,
|
||||
/// 使整条消息只剩 tool_result 类型 block。这样 Copilot 不会将其视为用户发起的交互。
|
||||
///
|
||||
/// 场景:Claude Code 在 skill 调用、edit hook、plan 提醒等场景下,会发送混合了
|
||||
/// tool_result + text 的用户消息。text block 的存在让 Copilot 将其计为 premium request。
|
||||
///
|
||||
/// **跨消息合并**(补充):连续的 tool_result-only 用户消息合并为一条。
|
||||
pub fn merge_tool_results(mut body: Value) -> Value {
|
||||
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
Some(msgs) if !msgs.is_empty() => msgs,
|
||||
_ => return body,
|
||||
};
|
||||
|
||||
// Phase 1: 消息内部合并 — 将 text block 吸收进 tool_result block
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
let content = match msg.get("content").and_then(|c| c.as_array()) {
|
||||
Some(blocks) => blocks,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// 分离 tool_result 和 text block
|
||||
let mut tool_results: Vec<Value> = Vec::new();
|
||||
let mut text_blocks: Vec<Value> = Vec::new();
|
||||
let mut valid = true;
|
||||
|
||||
for block in content {
|
||||
match block.get("type").and_then(|t| t.as_str()) {
|
||||
Some("tool_result") => tool_results.push(block.clone()),
|
||||
Some("text") => text_blocks.push(block.clone()),
|
||||
_ => {
|
||||
// 存在其他类型 block → 跳过此消息
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 必须同时有 tool_result 和 text 才需要合并
|
||||
if !valid || tool_results.is_empty() || text_blocks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 合并策略(与参考实现对齐)
|
||||
let merged = merge_blocks_into_tool_results(tool_results, text_blocks);
|
||||
msg["content"] = Value::Array(merged);
|
||||
}
|
||||
|
||||
// Phase 2: 跨消息合并 — 连续的 tool_result-only 用户消息合并
|
||||
let messages = body["messages"].as_array().unwrap().clone();
|
||||
if messages.len() <= 1 {
|
||||
return body;
|
||||
}
|
||||
|
||||
let mut merged_msgs: Vec<Value> = Vec::with_capacity(messages.len());
|
||||
let mut i = 0;
|
||||
|
||||
while i < messages.len() {
|
||||
if is_tool_result_only_message(&messages[i]) {
|
||||
let mut combined_content: Vec<Value> = Vec::new();
|
||||
while i < messages.len() && is_tool_result_only_message(&messages[i]) {
|
||||
if let Some(content) = messages[i].get("content").and_then(|c| c.as_array()) {
|
||||
combined_content.extend(content.iter().cloned());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !combined_content.is_empty() {
|
||||
merged_msgs.push(serde_json::json!({
|
||||
"role": "user",
|
||||
"content": combined_content
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
merged_msgs.push(messages[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
body["messages"] = Value::Array(merged_msgs);
|
||||
body
|
||||
}
|
||||
|
||||
/// 基于最后一条用户消息内容生成确定性 Request ID。
|
||||
///
|
||||
/// 与参考实现对齐:
|
||||
/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control)
|
||||
/// - 找不到用户内容时退化为随机 UUID
|
||||
/// - 使用 UUID v4 格式
|
||||
pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
|
||||
let last_user_content = find_last_user_content(body);
|
||||
|
||||
match last_user_content {
|
||||
Some(content) => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(session_id.as_bytes());
|
||||
hasher.update(content.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes.copy_from_slice(&result[..16]);
|
||||
// UUID v4 版本位和变体位(与参考实现一致)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||
|
||||
Uuid::from_bytes(bytes).to_string()
|
||||
}
|
||||
None => Uuid::new_v4().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 内部辅助 ─────────────────────────────────
|
||||
|
||||
/// 查找最后一条 user 消息的非 tool_result 文本内容。
|
||||
///
|
||||
/// 与参考实现的 `findLastUserContent` 对齐:
|
||||
/// - 从后往前遍历消息
|
||||
/// - 排除 tool_result block
|
||||
/// - 排除 cache_control 字段
|
||||
fn find_last_user_content(body: &Value) -> Option<String> {
|
||||
let messages = body.get("messages").and_then(|m| m.as_array())?;
|
||||
|
||||
for msg in messages.iter().rev() {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
let content = msg.get("content")?;
|
||||
|
||||
if let Some(s) = content.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
|
||||
if let Some(blocks) = content.as_array() {
|
||||
// 过滤 tool_result,保留其他 block(去掉 cache_control)
|
||||
let filtered: Vec<Value> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
|
||||
.map(|b| {
|
||||
let mut b = b.clone();
|
||||
if let Some(obj) = b.as_object_mut() {
|
||||
obj.remove("cache_control");
|
||||
}
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !filtered.is_empty() {
|
||||
return Some(serde_json::to_string(&filtered).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 将 text block 合并进 tool_result block。
|
||||
///
|
||||
/// 两种合并策略(与参考实现对齐):
|
||||
/// - 数量相等:一一对应,text 追加到对应 tool_result 的 content 中
|
||||
/// - 数量不等:所有 text 追加到最后一个 tool_result 的 content 中
|
||||
fn merge_blocks_into_tool_results(
|
||||
mut tool_results: Vec<Value>,
|
||||
text_blocks: Vec<Value>,
|
||||
) -> Vec<Value> {
|
||||
if tool_results.len() == text_blocks.len() {
|
||||
// 一一对应合并
|
||||
for (tr, tb) in tool_results.iter_mut().zip(text_blocks.iter()) {
|
||||
append_text_to_tool_result(tr, tb);
|
||||
}
|
||||
} else {
|
||||
// 所有 text 追加到最后一个 tool_result
|
||||
if let Some(last_tr) = tool_results.last_mut() {
|
||||
for tb in &text_blocks {
|
||||
append_text_to_tool_result(last_tr, tb);
|
||||
}
|
||||
}
|
||||
}
|
||||
tool_results
|
||||
}
|
||||
|
||||
/// 将 text block 的内容追加到 tool_result 的 content 中
|
||||
fn append_text_to_tool_result(tool_result: &mut Value, text_block: &Value) {
|
||||
let text = text_block
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// tool_result 的 content 可以是字符串或数组
|
||||
match tool_result.get("content") {
|
||||
Some(c) if c.is_string() => {
|
||||
let existing = c.as_str().unwrap_or("");
|
||||
tool_result["content"] = Value::String(format!("{existing}\n{text}"));
|
||||
}
|
||||
Some(c) if c.is_array() => {
|
||||
let arr = tool_result["content"].as_array_mut().unwrap();
|
||||
arr.push(serde_json::json!({"type": "text", "text": text}));
|
||||
}
|
||||
_ => {
|
||||
// content 缺失或 null — 直接设置
|
||||
tool_result["content"] = Value::String(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从消息中提取文本内容
|
||||
fn extract_text_from_message(msg: &Value) -> String {
|
||||
match msg.get("content") {
|
||||
Some(content) if content.is_string() => content.as_str().unwrap_or("").to_string(),
|
||||
Some(content) if content.is_array() => {
|
||||
let blocks = content.as_array().unwrap();
|
||||
blocks
|
||||
.iter()
|
||||
.filter_map(|block| {
|
||||
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
block.get("text").and_then(|t| t.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断消息是否为 tool_result-only 的用户消息
|
||||
fn is_tool_result_only_message(msg: &Value) -> bool {
|
||||
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
return false;
|
||||
}
|
||||
match msg.get("content").and_then(|c| c.as_array()) {
|
||||
Some(blocks) if !blocks.is_empty() => blocks
|
||||
.iter()
|
||||
.all(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 测试 ─────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// === classify_request 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_classify_user_text_message() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, please help me write some code"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_user_text_array_message() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "Please explain this code"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_tool_result_only() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "Read the file"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "I'll read that file."},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "Read", "input": {"path": "/tmp/test.rs"}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents here"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_tool_result_with_text_block() {
|
||||
// 参考实现的关键场景:tool_result + text block
|
||||
// 有非 tool_result block → 仍然是 "user"
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents"},
|
||||
{"type": "text", "text": "Now please refactor this code"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_empty_messages() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": []
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_no_messages() {
|
||||
let body = json!({"model": "claude-sonnet-4-20250514"});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_compact_request_system_prompt() {
|
||||
// compact 通过 system prompt 强特征检测
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"system": "You are a helpful AI assistant tasked with summarizing conversations. Please create a summary.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Here is the conversation history to summarize..."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_compact_request_critical_marker() {
|
||||
// compact 通过 CRITICAL 机器指令检测
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize the conversation."}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_compact_disabled_by_config() {
|
||||
// compact_detection=false 时,即使内容匹配也不标记为 compact
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"system": "You are a helpful AI assistant tasked with summarizing conversations.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Summarize"}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, false); // compact_detection=false
|
||||
assert_eq!(result.initiator, "user"); // 不被标记为 agent
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_false_positive_on_user_summarize_request() {
|
||||
// P1 修复验证:用户手动输入 "summarize the conversation" 不应被误判为 compact
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Please summarize the conversation so far into a concise summary."}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, false, true);
|
||||
// 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user
|
||||
assert_eq!(result.initiator, "user");
|
||||
assert!(!result.is_compact);
|
||||
}
|
||||
|
||||
// === warmup 测试(与参考实现对齐) ===
|
||||
|
||||
#[test]
|
||||
fn test_warmup_with_anthropic_beta_no_tools() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=true, 无 tools → warmup
|
||||
let result = classify_request(&body, true, true);
|
||||
assert!(result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_warmup_without_anthropic_beta() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
// has_anthropic_beta=false → 不是 warmup
|
||||
let result = classify_request(&body, false, true);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_warmup_with_tools() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
// 有 tools → 不是 warmup(即使有 anthropic-beta)
|
||||
let result = classify_request(&body, true, true);
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_warmup_when_agent() {
|
||||
// tool_result → agent → 不判定 warmup
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "toolu_123", "content": "ok"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = classify_request(&body, true, true);
|
||||
assert_eq!(result.initiator, "agent");
|
||||
assert!(!result.is_warmup);
|
||||
}
|
||||
|
||||
// === merge_tool_results 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_message_tool_result_text() {
|
||||
// 核心场景:消息内部 tool_result + text → text 被吸收进 tool_result
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "file contents"},
|
||||
{"type": "text", "text": "skill output here"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
// 应只剩 1 个 tool_result block(text 被吸收)
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0]["type"], "tool_result");
|
||||
// tool_result 的 content 应包含原始内容 + 吸收的 text
|
||||
let tr_content = content[0]["content"].as_str().unwrap();
|
||||
assert!(tr_content.contains("file contents"));
|
||||
assert!(tr_content.contains("skill output here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_message_equal_count() {
|
||||
// 数量相等:一一对应合并
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "result1"},
|
||||
{"type": "text", "text": "text1"},
|
||||
{"type": "tool_result", "tool_use_id": "t2", "content": "result2"},
|
||||
{"type": "text", "text": "text2"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 2);
|
||||
assert!(content[0]["content"].as_str().unwrap().contains("text1"));
|
||||
assert!(content[1]["content"].as_str().unwrap().contains("text2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_message_empty_text_ignored() {
|
||||
// 空 text block 不追加内容
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "result"},
|
||||
{"type": "text", "text": ""}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 1);
|
||||
// 空 text 不改变原始 content
|
||||
assert_eq!(content[0]["content"], "result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_intra_skips_other_block_types() {
|
||||
// 有非 tool_result/text 的 block → 跳过整条消息
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "result"},
|
||||
{"type": "image", "source": {"data": "..."}},
|
||||
{"type": "text", "text": "caption"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
// 未合并,保持原样 3 个 block
|
||||
assert_eq!(content.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_cross_message_consecutive() {
|
||||
// 跨消息合并:连续 tool_result-only 用户消息
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Read files"},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": "t1", "name": "Read", "input": {}},
|
||||
{"type": "tool_use", "id": "t2", "name": "Read", "input": {}}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "file1"}
|
||||
]},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t2", "content": "file2"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body);
|
||||
let messages = result["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 3);
|
||||
let merged_content = messages[2]["content"].as_array().unwrap();
|
||||
assert_eq!(merged_content.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_does_not_affect_normal_messages() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "How are you?"}
|
||||
]
|
||||
});
|
||||
let result = merge_tool_results(body.clone());
|
||||
assert_eq!(result["messages"], body["messages"]);
|
||||
}
|
||||
|
||||
// === deterministic_request_id 测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_stable() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body, "session1");
|
||||
let id2 = deterministic_request_id(&body, "session1");
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_varies_by_content() {
|
||||
let body1 = json!({
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let body2 = json!({
|
||||
"messages": [{"role": "user", "content": "Goodbye"}]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body1, "session1");
|
||||
let id2 = deterministic_request_id(&body2, "session1");
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_varies_by_session() {
|
||||
let body = json!({
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body, "session1");
|
||||
let id2 = deterministic_request_id(&body, "session2");
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_ignores_tool_result() {
|
||||
// tool_result 内容不同,但 user text 相同 → 相同 ID
|
||||
let body1 = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "version_A"}
|
||||
]},
|
||||
{"role": "user", "content": "do something"}
|
||||
]
|
||||
});
|
||||
let body2 = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "version_B"}
|
||||
]},
|
||||
{"role": "user", "content": "do something"}
|
||||
]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body1, "s");
|
||||
let id2 = deterministic_request_id(&body2, "s");
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_fallback_when_no_user_content() {
|
||||
// 无用户消息 → 退化为随机 UUID(每次不同)
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "assistant", "content": "Hi"}
|
||||
]
|
||||
});
|
||||
let id1 = deterministic_request_id(&body, "s");
|
||||
let id2 = deterministic_request_id(&body, "s");
|
||||
// 随机 UUID,每次应不同
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deterministic_id_is_valid_uuid() {
|
||||
let body = json!({
|
||||
"messages": [{"role": "user", "content": "test"}]
|
||||
});
|
||||
let id = deterministic_request_id(&body, "session");
|
||||
assert!(Uuid::parse_str(&id).is_ok());
|
||||
}
|
||||
|
||||
// === compact 检测增强测试 ===
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_system_prompt() {
|
||||
let body = json!({
|
||||
"system": "You are a helpful AI assistant tasked with summarizing conversations. Please provide a concise summary.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Here is the conversation to summarize..."}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_critical_keyword() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize this conversation."}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_structural_markers() {
|
||||
// Claude Code compact 特有的结构标记
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Summary of conversation:\n\nPending Tasks:\n- Fix bug\n\nCurrent Work:\n- Implementing feature"}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_no_false_positive_on_generic_summary() {
|
||||
// 通用短语不应触发 compact 检测
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "Your task is to create a detailed summary of the conversation so far."}
|
||||
]
|
||||
});
|
||||
assert!(!is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_negative() {
|
||||
let body = json!({
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the weather today?"}
|
||||
]
|
||||
});
|
||||
assert!(!is_compact_request(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_detection_system_array() {
|
||||
let body = json!({
|
||||
"system": [
|
||||
{"type": "text", "text": "You are a helpful AI assistant tasked with summarizing conversations."}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "user", "content": "Summarize"}
|
||||
]
|
||||
});
|
||||
assert!(is_compact_request(&body));
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,12 @@
|
||||
//!
|
||||
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
||||
//! - 去重控制(避免多个请求同时触发)
|
||||
//! - 数据库更新
|
||||
//! - 托盘菜单更新
|
||||
//! - 前端事件发射
|
||||
//! - Live 备份更新
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -98,30 +95,21 @@ impl FailoverSwitchManager {
|
||||
|
||||
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||
|
||||
// 1. 更新数据库 is_current
|
||||
self.db.set_current_provider(app_type, provider_id)?;
|
||||
let mut switched = false;
|
||||
|
||||
// 2. 更新本地 settings(设备级)
|
||||
let app_type_enum = crate::app_config::AppType::from_str(app_type)
|
||||
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
|
||||
|
||||
// 3. 更新托盘菜单和发射事件
|
||||
if let Some(app) = app_handle {
|
||||
// 更新托盘菜单
|
||||
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
|
||||
// 更新 Live 备份(确保代理停止时恢复正确配置)
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||
if let Err(e) = app_state
|
||||
.proxy_service
|
||||
.update_live_backup_from_provider(app_type, &provider)
|
||||
.await
|
||||
{
|
||||
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||
}
|
||||
switched = app_state
|
||||
.proxy_service
|
||||
.hot_switch_provider(app_type, provider_id)
|
||||
.await
|
||||
.map_err(AppError::Message)?
|
||||
.logical_target_changed;
|
||||
|
||||
if !switched {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 重建托盘菜单
|
||||
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
@@ -142,6 +130,6 @@ impl FailoverSwitchManager {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
Ok(switched)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::{
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use crate::commands::CopilotAuthState;
|
||||
@@ -52,6 +52,8 @@ pub struct RequestForwarder {
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
@@ -70,6 +72,7 @@ impl RequestForwarder {
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
router,
|
||||
@@ -80,6 +83,7 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
@@ -747,7 +751,7 @@ impl RequestForwarder {
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
let mut base_url = adapter.extract_base_url(provider)?;
|
||||
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
@@ -760,7 +764,7 @@ impl RequestForwarder {
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
@@ -770,6 +774,92 @@ impl RequestForwarder {
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) ---
|
||||
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
|
||||
let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled {
|
||||
// 1. Tool result 合并 — 必须在分类之前执行
|
||||
// 合并将 [tool_result, text] 变为 [tool_result(含text)],
|
||||
// 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block)
|
||||
if self.copilot_optimizer_config.tool_result_merging {
|
||||
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
|
||||
}
|
||||
|
||||
// 2. 在合并后的 body 上进行分类
|
||||
let has_anthropic_beta = headers.contains_key("anthropic-beta");
|
||||
let classification = super::copilot_optimizer::classify_request(
|
||||
&mapped_body,
|
||||
has_anthropic_beta,
|
||||
self.copilot_optimizer_config.compact_detection,
|
||||
);
|
||||
|
||||
log::debug!(
|
||||
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}",
|
||||
classification.initiator,
|
||||
classification.is_warmup,
|
||||
classification.is_compact
|
||||
);
|
||||
|
||||
// 3. Warmup 小模型降级
|
||||
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
|
||||
log::info!(
|
||||
"[Copilot] Warmup 请求降级到模型: {}",
|
||||
self.copilot_optimizer_config.warmup_model
|
||||
);
|
||||
mapped_body["model"] =
|
||||
serde_json::json!(&self.copilot_optimizer_config.warmup_model);
|
||||
}
|
||||
|
||||
// 预计算确定性 Request ID(在 body 被 move 之前)
|
||||
// 使用 session_id 从 body.metadata.user_id 或请求头提取
|
||||
let session_id = body
|
||||
.pointer("/metadata/user_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok()))
|
||||
.unwrap_or("");
|
||||
let det_request_id = if self.copilot_optimizer_config.deterministic_request_id {
|
||||
Some(super::copilot_optimizer::deterministic_request_id(
|
||||
&mapped_body,
|
||||
session_id,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some((classification, det_request_id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// GitHub Copilot 动态 endpoint 路由
|
||||
// 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint)
|
||||
if is_copilot && !is_full_url {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||
let copilot_auth = 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"));
|
||||
|
||||
let dynamic_endpoint = match &account_id {
|
||||
Some(id) => copilot_auth.get_api_endpoint(id).await,
|
||||
None => copilot_auth.get_default_api_endpoint().await,
|
||||
};
|
||||
|
||||
// 只在动态 endpoint 与当前 base_url 不同时替换
|
||||
if dynamic_endpoint != base_url {
|
||||
log::debug!(
|
||||
"[Copilot] 使用动态 API endpoint: {} (原: {})",
|
||||
dynamic_endpoint,
|
||||
base_url
|
||||
);
|
||||
base_url = dynamic_endpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resolved_claude_api_format = if adapter.name() == "Claude" {
|
||||
Some(
|
||||
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
|
||||
@@ -828,7 +918,7 @@ impl RequestForwarder {
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
let mut auth_headers = 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 {
|
||||
@@ -884,6 +974,25 @@ impl RequestForwarder {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
|
||||
for (name, value) in auth_headers.iter_mut() {
|
||||
match name.as_str() {
|
||||
"x-initiator" if self.copilot_optimizer_config.request_classification => {
|
||||
*value = http::HeaderValue::from_static(classification.initiator);
|
||||
}
|
||||
"x-request-id" | "x-agent-task-id" => {
|
||||
if let Some(ref det_id) = det_request_id {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(det_id) {
|
||||
*value = hv;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
|
||||
let copilot_fingerprint_headers: &[&str] = if is_copilot {
|
||||
&[
|
||||
@@ -893,6 +1002,12 @@ impl RequestForwarder {
|
||||
"copilot-integration-id",
|
||||
"x-github-api-version",
|
||||
"openai-intent",
|
||||
// 新增 headers
|
||||
"x-initiator",
|
||||
"x-interaction-type",
|
||||
"x-vscode-user-agent-library-version",
|
||||
"x-request-id",
|
||||
"x-agent-task-id",
|
||||
]
|
||||
} else {
|
||||
&[]
|
||||
@@ -1663,4 +1778,107 @@ mod tests {
|
||||
&headers
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Copilot 动态 endpoint 路由相关测试 ====================
|
||||
|
||||
/// 验证 is_copilot 检测逻辑:通过 provider_type 判断
|
||||
#[test]
|
||||
fn copilot_detection_via_provider_type() {
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
|
||||
let provider = Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Copilot".to_string(),
|
||||
settings_config: serde_json::json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot");
|
||||
|
||||
assert!(is_copilot, "应该通过 provider_type 检测为 Copilot");
|
||||
}
|
||||
|
||||
/// 验证 is_copilot 检测逻辑:通过 base_url 判断
|
||||
#[test]
|
||||
fn copilot_detection_via_base_url() {
|
||||
let base_url = "https://api.githubcopilot.com";
|
||||
let is_copilot = base_url.contains("githubcopilot.com");
|
||||
assert!(is_copilot, "应该通过 base_url 检测为 Copilot");
|
||||
|
||||
let non_copilot_url = "https://api.anthropic.com";
|
||||
let is_not_copilot = non_copilot_url.contains("githubcopilot.com");
|
||||
assert!(!is_not_copilot, "非 Copilot URL 不应被检测为 Copilot");
|
||||
}
|
||||
|
||||
/// 验证企业版 endpoint(不包含 githubcopilot.com)场景下 is_copilot 仍然正确
|
||||
#[test]
|
||||
fn copilot_detection_for_enterprise_endpoint() {
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
|
||||
// 企业版场景:provider_type 是 github_copilot,但 base_url 可能是企业内部域名
|
||||
let provider = Provider {
|
||||
id: "enterprise".to_string(),
|
||||
name: "Enterprise Copilot".to_string(),
|
||||
settings_config: serde_json::json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: Some(ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
let enterprise_base_url = "https://copilot-api.corp.example.com";
|
||||
|
||||
// is_copilot 应该通过 provider_type 检测成功,即使 base_url 不包含 githubcopilot.com
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| enterprise_base_url.contains("githubcopilot.com");
|
||||
|
||||
assert!(
|
||||
is_copilot,
|
||||
"企业版 Copilot 应该通过 provider_type 被正确检测"
|
||||
);
|
||||
}
|
||||
|
||||
/// 验证动态 endpoint 替换条件
|
||||
#[test]
|
||||
fn dynamic_endpoint_replacement_conditions() {
|
||||
// 条件:is_copilot && !is_full_url
|
||||
let test_cases = [
|
||||
(true, false, true, "Copilot + 非 full_url 应该替换"),
|
||||
(true, true, false, "Copilot + full_url 不应替换"),
|
||||
(false, false, false, "非 Copilot 不应替换"),
|
||||
(false, true, false, "非 Copilot + full_url 不应替换"),
|
||||
];
|
||||
|
||||
for (is_copilot, is_full_url, should_replace, desc) in test_cases {
|
||||
let will_replace = is_copilot && !is_full_url;
|
||||
assert_eq!(will_replace, should_replace, "{desc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, OptimizerConfig, RectifierConfig},
|
||||
types::{AppProxyConfig, CopilotOptimizerConfig, OptimizerConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -61,6 +61,8 @@ pub struct RequestContext {
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
/// Copilot 优化器配置
|
||||
pub copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -96,6 +98,7 @@ impl RequestContext {
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();
|
||||
let copilot_optimizer_config = state.db.get_copilot_optimizer_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
@@ -160,6 +163,7 @@ impl RequestContext {
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +225,7 @@ impl RequestContext {
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
self.copilot_optimizer_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -359,6 +359,10 @@ async fn send_raw_request(
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
tls_stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Flush failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(tls_stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
@@ -368,6 +372,10 @@ async fn send_raw_request(
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Flush failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
@@ -441,6 +449,10 @@ async fn connect_via_proxy(
|
||||
.write_all(connect_req.as_bytes())
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT flush failed: {e}")))?;
|
||||
|
||||
// Read the proxy's response status line
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub mod copilot_optimizer;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
pub(crate) mod failover_switch;
|
||||
@@ -24,6 +25,7 @@ pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub(crate) mod sse;
|
||||
pub(crate) mod switch_lock;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
|
||||
@@ -272,6 +272,7 @@ mod tests {
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
original_test_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
@@ -279,15 +280,18 @@ 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());
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
Self {
|
||||
dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
original_test_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,6 +307,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +348,8 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
)]
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
@@ -373,9 +375,30 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderName::from_static("x-github-api-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
|
||||
),
|
||||
// 26-04-01新增的copilot关键 headers
|
||||
(
|
||||
HeaderName::from_static("openai-intent"),
|
||||
HeaderValue::from_static("conversation-panel"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-initiator"),
|
||||
HeaderValue::from_static("user"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-interaction-type"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||||
HeaderValue::from_static("electron-fetch"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-request-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-agent-task-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,15 +46,18 @@ const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60;
|
||||
const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
|
||||
|
||||
/// Copilot API Header 常量
|
||||
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0";
|
||||
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
|
||||
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
|
||||
pub const COPILOT_API_VERSION: &str = "2025-04-01";
|
||||
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.110.1";
|
||||
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.38.2";
|
||||
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.38.2";
|
||||
pub const COPILOT_API_VERSION: &str = "2025-10-01";
|
||||
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
|
||||
|
||||
/// Copilot 使用量 API URL
|
||||
const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user";
|
||||
|
||||
/// 默认 Copilot API 端点
|
||||
const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com";
|
||||
|
||||
/// Copilot 使用量响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CopilotUsageResponse {
|
||||
@@ -64,6 +67,19 @@ pub struct CopilotUsageResponse {
|
||||
pub quota_reset_date: String,
|
||||
/// 配额快照
|
||||
pub quota_snapshots: QuotaSnapshots,
|
||||
/// API 端点信息 (用于动态获取 API URL)
|
||||
#[serde(default)]
|
||||
pub endpoints: Option<CopilotEndpoints>,
|
||||
}
|
||||
|
||||
/// Copilot API 端点信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CopilotEndpoints {
|
||||
/// API 端点 URL
|
||||
pub api: String,
|
||||
/// Telemetry 端点 URL
|
||||
#[serde(default)]
|
||||
pub telemetry: Option<String>,
|
||||
}
|
||||
|
||||
/// 配额快照
|
||||
@@ -312,6 +328,10 @@ pub struct CopilotAuthManager {
|
||||
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
|
||||
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
|
||||
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
|
||||
/// Copilot API 端点缓存(key = GitHub user ID,从 /copilot_internal/user 获取)
|
||||
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
|
||||
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
/// HTTP 客户端
|
||||
http_client: Client,
|
||||
/// 存储路径
|
||||
@@ -333,6 +353,8 @@ impl CopilotAuthManager {
|
||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
|
||||
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
pending_migration: Arc::new(RwLock::new(None)),
|
||||
@@ -386,6 +408,15 @@ impl CopilotAuthManager {
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.remove(account_id);
|
||||
}
|
||||
// 清理 API 端点缓存
|
||||
{
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.remove(account_id);
|
||||
}
|
||||
{
|
||||
let mut endpoint_locks = self.endpoint_locks.write().await;
|
||||
endpoint_locks.remove(account_id);
|
||||
}
|
||||
|
||||
{
|
||||
let accounts = self.accounts.read().await;
|
||||
@@ -775,6 +806,14 @@ impl CopilotAuthManager {
|
||||
.await
|
||||
.map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;
|
||||
|
||||
// 存储动态 API 端点(如果有)
|
||||
if let Some(ref endpoints) = usage.endpoints {
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.insert(account_id.to_string(), endpoints.api.clone());
|
||||
// 使用 debug 级别避免在日志中暴露企业内部域名
|
||||
log::debug!("[CopilotAuth] 账号 {account_id} 已保存动态 API 端点");
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[CopilotAuth] 获取使用量成功,计划: {}, 重置日期: {}",
|
||||
usage.copilot_plan,
|
||||
@@ -794,6 +833,118 @@ impl CopilotAuthManager {
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
/// 获取指定账号的 API 端点(缓存命中直接返回,未命中则从 API 惰性拉取)
|
||||
pub async fn get_api_endpoint(&self, account_id: &str) -> String {
|
||||
let _ = self.ensure_migration_complete().await;
|
||||
|
||||
{
|
||||
let endpoints = self.api_endpoints.read().await;
|
||||
if let Some(endpoint) = endpoints.get(account_id) {
|
||||
return endpoint.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 用锁串行化同一账号的并发拉取,避免对 GitHub API 的重复请求
|
||||
let lock = self.get_endpoint_lock(account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
// 持锁后二次检查:可能已由其他请求填充
|
||||
{
|
||||
let endpoints = self.api_endpoints.read().await;
|
||||
if let Some(endpoint) = endpoints.get(account_id) {
|
||||
return endpoint.clone();
|
||||
}
|
||||
}
|
||||
|
||||
match self.fetch_and_cache_endpoint(account_id).await {
|
||||
Ok(endpoint) => endpoint,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"[CopilotAuth] 获取账号 {account_id} 动态 API 端点失败: {e},使用默认值"
|
||||
);
|
||||
DEFAULT_COPILOT_API_ENDPOINT.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取默认账号的 API 端点
|
||||
pub async fn get_default_api_endpoint(&self) -> String {
|
||||
let _ = self.ensure_migration_complete().await;
|
||||
|
||||
match self.resolve_default_account_id().await {
|
||||
Some(id) => self.get_api_endpoint(&id).await,
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_and_cache_endpoint(&self, account_id: &str) -> Result<String, CopilotAuthError> {
|
||||
let github_token = {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_token.clone())
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
};
|
||||
|
||||
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_USAGE_URL)
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", COPILOT_PLUGIN_VERSION)
|
||||
.header("user-agent", COPILOT_USER_AGENT)
|
||||
.header("x-github-api-version", COPILOT_API_VERSION)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(CopilotAuthError::GitHubTokenInvalid);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"获取 API 端点失败: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let usage: CopilotUsageResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;
|
||||
|
||||
let endpoint = match usage.endpoints {
|
||||
Some(endpoints) => endpoints.api.clone(),
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
};
|
||||
|
||||
// 缓存端点(包括默认值),避免重复请求
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.insert(account_id.to_string(), endpoint.clone());
|
||||
log::debug!("[CopilotAuth] 账号 {account_id} 已缓存 API 端点");
|
||||
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
async fn get_endpoint_lock(&self, account_id: &str) -> Arc<Mutex<()>> {
|
||||
{
|
||||
let locks = self.endpoint_locks.read().await;
|
||||
if let Some(lock) = locks.get(account_id) {
|
||||
return Arc::clone(lock);
|
||||
}
|
||||
}
|
||||
|
||||
let mut locks = self.endpoint_locks.write().await;
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(account_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取认证状态(支持多账号)
|
||||
pub async fn get_status(&self) -> CopilotAuthStatus {
|
||||
// 确保迁移完成
|
||||
@@ -838,6 +989,7 @@ impl CopilotAuthManager {
|
||||
pub async fn clear_auth(&self) -> Result<(), CopilotAuthError> {
|
||||
log::info!("[CopilotAuth] 清除所有认证");
|
||||
|
||||
// 先清理内存状态,确保即使文件删除失败用户也能看到已登出
|
||||
{
|
||||
let mut accounts = self.accounts.write().await;
|
||||
accounts.clear();
|
||||
@@ -851,12 +1003,25 @@ impl CopilotAuthManager {
|
||||
let mut tokens = self.copilot_tokens.write().await;
|
||||
tokens.clear();
|
||||
}
|
||||
{
|
||||
let mut models = self.copilot_models.write().await;
|
||||
models.clear();
|
||||
}
|
||||
{
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.clear();
|
||||
}
|
||||
// 清理 API 端点缓存
|
||||
{
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.clear();
|
||||
}
|
||||
{
|
||||
let mut endpoint_locks = self.endpoint_locks.write().await;
|
||||
endpoint_locks.clear();
|
||||
}
|
||||
|
||||
// 删除存储文件
|
||||
// 最后删除存储文件
|
||||
if self.storage_path.exists() {
|
||||
std::fs::remove_file(&self.storage_path)?;
|
||||
}
|
||||
@@ -1414,4 +1579,242 @@ mod tests {
|
||||
let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap();
|
||||
assert_eq!(default_vendor.as_deref(), Some("Anthropic"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_cached_value() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 手动设置 api_endpoints 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, "https://copilot-api.enterprise.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_default_when_not_cached() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let endpoint = manager.get_api_endpoint("99999").await;
|
||||
assert_eq!(endpoint, "https://api.githubcopilot.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_default_api_endpoint_uses_default_account() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 设置默认账号
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
// 添加账号数据
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
// 设置 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.corp.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let endpoint = manager.get_default_api_endpoint().await;
|
||||
assert_eq!(endpoint, "https://copilot-api.corp.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_account_clears_api_endpoint_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 添加账号数据
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
// 设置 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// 确认缓存存在
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.contains_key("12345"));
|
||||
}
|
||||
|
||||
// 移除账号
|
||||
manager.remove_account("12345").await.unwrap();
|
||||
|
||||
// 确认缓存已清理
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(!api_endpoints.contains_key("12345"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_auth_clears_all_api_endpoint_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 添加多个账号的 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise1.example.com".to_string(),
|
||||
);
|
||||
api_endpoints.insert(
|
||||
"67890".to_string(),
|
||||
"https://copilot-api.enterprise2.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// 确认缓存存在
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert_eq!(api_endpoints.len(), 2);
|
||||
}
|
||||
|
||||
// 清除所有认证
|
||||
manager.clear_auth().await.unwrap();
|
||||
|
||||
// 确认缓存已清空
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_auth_cleans_memory_even_when_file_removal_fails() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// Create a directory at storage_path so remove_file fails
|
||||
std::fs::create_dir_all(&manager.storage_path).unwrap();
|
||||
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let result = manager.clear_auth().await;
|
||||
// Should still return an error for the file deletion failure
|
||||
assert!(result.is_err());
|
||||
|
||||
// But memory state should already be cleaned
|
||||
let accounts = manager.accounts.read().await;
|
||||
assert!(accounts.is_empty());
|
||||
drop(accounts);
|
||||
|
||||
let default_account_id = manager.default_account_id.read().await;
|
||||
assert!(default_account_id.is_none());
|
||||
drop(default_account_id);
|
||||
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_cache_hit_skips_fetch() {
|
||||
// 缓存命中时应直接返回,不发起网络请求
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let enterprise_endpoint = "https://copilot-api.enterprise.example.com".to_string();
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert("12345".to_string(), enterprise_endpoint.clone());
|
||||
}
|
||||
|
||||
// 即使没有账号数据,缓存命中也应直接返回
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, enterprise_endpoint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_default_for_unknown_account() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, DEFAULT_COPILOT_API_ENDPOINT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_and_cache_endpoint_requires_account() {
|
||||
// 账号不存在时 fetch_and_cache_endpoint 应返回 AccountNotFound 错误
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let result = manager.fetch_and_cache_endpoint("nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CopilotAuthError::AccountNotFound(id) => assert_eq!(id, "nonexistent"),
|
||||
other => panic!("期望 AccountNotFound 错误,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,7 +974,9 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let events: Vec<Value> = chunks
|
||||
|
||||
@@ -113,6 +113,7 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
}
|
||||
}
|
||||
|
||||
normalize_openai_system_messages(&mut messages);
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数 — o-series 模型需要 max_completion_tokens
|
||||
@@ -182,6 +183,73 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
let system_count = messages
|
||||
.iter()
|
||||
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system"))
|
||||
.count();
|
||||
|
||||
if system_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if system_count == 1 {
|
||||
if let Some(index) = messages.iter().position(|message| {
|
||||
message.get("role").and_then(|value| value.as_str()) == Some("system")
|
||||
}) {
|
||||
if index > 0 {
|
||||
let message = messages.remove(index);
|
||||
messages.insert(0, message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut inherited_cache_control: Option<Value> = None;
|
||||
let mut cache_control_conflict = false;
|
||||
messages.retain(|message| {
|
||||
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match message.get("content") {
|
||||
Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()),
|
||||
Some(Value::Array(content_parts)) => {
|
||||
let text = content_parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(|value| value.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !text.is_empty() {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(cache_control) = message.get("cache_control") {
|
||||
match &inherited_cache_control {
|
||||
None => inherited_cache_control = Some(cache_control.clone()),
|
||||
Some(existing) if existing == cache_control => {}
|
||||
Some(_) => cache_control_conflict = true,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
let mut merged = json!({"role": "system", "content": parts.join("\n")});
|
||||
if !cache_control_conflict {
|
||||
if let Some(cache_control) = inherited_cache_control {
|
||||
merged["cache_control"] = cache_control;
|
||||
}
|
||||
}
|
||||
messages.insert(0, merged);
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
fn convert_message_to_openai(
|
||||
role: &str,
|
||||
@@ -560,6 +628,50 @@ mod tests {
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise."}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "system", "content": "Follow repo conventions."},
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise.\nFollow repo conventions."
|
||||
);
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
|
||||
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(result["messages"][0]["content"], "You are Claude Code.\nBe concise.");
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let input = json!({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Per-app switch lock
|
||||
//!
|
||||
//! 确保同一应用同时只有一个供应商切换操作在执行,
|
||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
||||
|
||||
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
||||
///
|
||||
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SwitchLockManager {
|
||||
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl SwitchLockManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 获取指定应用的切换锁。
|
||||
///
|
||||
/// 返回 `OwnedMutexGuard`,持有期间同一 `app_type` 的其他切换会排队等待。
|
||||
pub async fn lock_for_app(&self, app_type: &str) -> OwnedMutexGuard<()> {
|
||||
let lock = {
|
||||
let locks = self.locks.read().await;
|
||||
if let Some(lock) = locks.get(app_type) {
|
||||
lock.clone()
|
||||
} else {
|
||||
drop(locks);
|
||||
let mut locks = self.locks.write().await;
|
||||
locks
|
||||
.entry(app_type.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
};
|
||||
lock.lock_owned().await
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,54 @@ impl Default for OptimizerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copilot 优化器配置
|
||||
///
|
||||
/// 存储在 settings 表中,key = "copilot_optimizer_config"
|
||||
/// 解决 Copilot 代理消耗量异常问题(Issue #1813)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotOptimizerConfig {
|
||||
/// 总开关(默认开启 — 对 Copilot 用户至关重要)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// x-initiator 请求分类(默认开启,P0 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub request_classification: bool,
|
||||
/// Tool result 消息合并(默认开启,P1 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub tool_result_merging: bool,
|
||||
/// Compact 请求识别(默认开启,P2 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub compact_detection: bool,
|
||||
/// 确定性 Request ID(默认开启,P3 优先级)
|
||||
#[serde(default = "default_true")]
|
||||
pub deterministic_request_id: bool,
|
||||
/// Warmup 小模型降级(默认关闭,P4 优先级,opt-in)
|
||||
#[serde(default)]
|
||||
pub warmup_downgrade: bool,
|
||||
/// Warmup 降级使用的模型(默认 "gpt-4o-mini")
|
||||
#[serde(default = "default_warmup_model")]
|
||||
pub warmup_model: String,
|
||||
}
|
||||
|
||||
fn default_warmup_model() -> String {
|
||||
"gpt-4o-mini".to_string()
|
||||
}
|
||||
|
||||
impl Default for CopilotOptimizerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_classification: true,
|
||||
tool_result_merging: true,
|
||||
compact_detection: true,
|
||||
deterministic_request_id: true,
|
||||
warmup_downgrade: false,
|
||||
warmup_model: "gpt-4o-mini".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! 国产 Token Plan 额度查询服务
|
||||
//!
|
||||
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。
|
||||
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
|
||||
|
||||
use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ── 供应商检测 ──────────────────────────────────────────────
|
||||
|
||||
enum CodingPlanProvider {
|
||||
Kimi,
|
||||
ZhipuCn,
|
||||
ZhipuEn,
|
||||
MiniMaxCn,
|
||||
MiniMaxEn,
|
||||
}
|
||||
|
||||
fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
let url = base_url.to_lowercase();
|
||||
if url.contains("api.kimi.com/coding") {
|
||||
Some(CodingPlanProvider::Kimi)
|
||||
} else if url.contains("open.bigmodel.cn") || url.contains("bigmodel.cn") {
|
||||
Some(CodingPlanProvider::ZhipuCn)
|
||||
} else if url.contains("api.z.ai") {
|
||||
Some(CodingPlanProvider::ZhipuEn)
|
||||
} else if url.contains("api.minimaxi.com") {
|
||||
Some(CodingPlanProvider::MiniMaxCn)
|
||||
} else if url.contains("api.minimax.io") {
|
||||
Some(CodingPlanProvider::MiniMaxEn)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn millis_to_iso8601(ms: i64) -> Option<String> {
|
||||
let secs = ms / 1000;
|
||||
let nsecs = ((ms % 1000) * 1_000_000) as u32;
|
||||
chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339())
|
||||
}
|
||||
|
||||
/// 从 JSON 值提取重置时间,兼容字符串和数字格式
|
||||
/// - 字符串:直接返回(ISO 8601)
|
||||
/// - 数字:自动判断秒/毫秒并转为 ISO 8601
|
||||
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
if let Some(n) = value.as_i64() {
|
||||
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
|
||||
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
|
||||
return millis_to_iso8601(ms);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`)
|
||||
fn parse_f64(value: &serde_json::Value) -> Option<f64> {
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
|
||||
}
|
||||
|
||||
fn make_error(msg: String) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(msg),
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kimi For Coding ─────────────────────────────────────────
|
||||
|
||||
async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
.get("https://api.kimi.com/coding/v1/usages")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
// 5 小时窗口限额(优先显示)
|
||||
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
if let Some(detail) = limit_item.get("detail") {
|
||||
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
let resets_at = detail.get("resetTime").and_then(extract_reset_time);
|
||||
|
||||
let used = (limit - remaining).max(0.0);
|
||||
let utilization = if limit > 0.0 {
|
||||
(used / limit) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 总体用量(周限额)
|
||||
if let Some(usage) = body.get("usage") {
|
||||
let limit = usage.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = usage.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
let resets_at = usage.get("resetTime").and_then(extract_reset_time);
|
||||
|
||||
let used = (limit - remaining).max(0.0);
|
||||
let utilization = if limit > 0.0 {
|
||||
(used / limit) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
tiers.push(QuotaTier {
|
||||
name: "weekly_limit".to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
});
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||
|
||||
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
// 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制)
|
||||
let resp = client
|
||||
.get("https://api.z.ai/api/monitor/usage/quota/limit")
|
||||
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Language", "en-US,en")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
|
||||
let msg = body
|
||||
.get("msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error: {msg}"));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => return make_error("Missing 'data' field in response".to_string()),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
let limit_type = limit_item
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let percentage = limit_item
|
||||
.get("percentage")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let next_reset = limit_item
|
||||
.get("nextResetTime")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(millis_to_iso8601);
|
||||
|
||||
if limit_type != "TOKENS_LIMIT" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization: percentage,
|
||||
resets_at: next_reset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 套餐等级存入 credential_message
|
||||
let level = data
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: level,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── MiniMax ─────────────────────────────────────────────────
|
||||
|
||||
async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let api_domain = if is_cn {
|
||||
"api.minimaxi.com"
|
||||
} else {
|
||||
"api.minimax.io"
|
||||
};
|
||||
let url = format!("https://{api_domain}/v1/api/openplatform/coding_plan/remains");
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
if let Some(base_resp) = body.get("base_resp") {
|
||||
let status_code = base_resp
|
||||
.get("status_code")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
if status_code != 0 {
|
||||
let msg = base_resp
|
||||
.get("status_msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error (code {status_code}): {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) {
|
||||
// 只取第一个模型(MiniMax-M*,主力编程模型)
|
||||
if let Some(item) = model_remains.first() {
|
||||
// usage_count 是剩余量(满额=total,用完=0),需反转为已用百分比
|
||||
let interval_total = item
|
||||
.get("current_interval_total_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let interval_remaining = item
|
||||
.get("current_interval_usage_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let end_time = item.get("end_time").and_then(|v| v.as_i64());
|
||||
|
||||
if interval_total > 0.0 {
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization: ((interval_total - interval_remaining) / interval_total) * 100.0,
|
||||
resets_at: end_time.and_then(millis_to_iso8601),
|
||||
});
|
||||
}
|
||||
|
||||
// 周额度
|
||||
let weekly_total = item
|
||||
.get("current_weekly_total_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let weekly_remaining = item
|
||||
.get("current_weekly_usage_count")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let weekly_end = item.get("weekly_end_time").and_then(|v| v.as_i64());
|
||||
|
||||
if weekly_total > 0.0 {
|
||||
tiers.push(QuotaTier {
|
||||
name: "weekly_limit".to_string(),
|
||||
utilization: ((weekly_total - weekly_remaining) / weekly_total) * 100.0,
|
||||
resets_at: weekly_end.and_then(millis_to_iso8601),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: None,
|
||||
});
|
||||
}
|
||||
|
||||
let provider = match detect_provider(base_url) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let quota = match provider {
|
||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await,
|
||||
CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await,
|
||||
CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await,
|
||||
};
|
||||
|
||||
Ok(quota)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod coding_plan;
|
||||
pub mod config;
|
||||
pub mod env_checker;
|
||||
pub mod env_manager;
|
||||
pub mod mcp;
|
||||
pub mod model_fetch;
|
||||
pub mod omo;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
@@ -9,6 +11,7 @@ pub mod proxy;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod subscription;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
pub mod webdav_auto_sync;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 模型列表获取服务
|
||||
//!
|
||||
//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。
|
||||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 获取到的模型信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FetchedModel {
|
||||
pub id: String,
|
||||
pub owned_by: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 兼容的 /v1/models 响应格式
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsResponse {
|
||||
data: Option<Vec<ModelEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
owned_by: Option<String>,
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
pub async fn fetch_models(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
is_full_url: bool,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
if api_key.is_empty() {
|
||||
return Err("API Key is required to fetch models".to_string());
|
||||
}
|
||||
|
||||
let models_url = build_models_url(base_url, is_full_url)?;
|
||||
let client = crate::proxy::http_client::get_for_provider(None);
|
||||
|
||||
let response = client
|
||||
.get(&models_url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
let resp: ModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
let mut models: Vec<FetchedModel> = resp
|
||||
.data
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| FetchedModel {
|
||||
id: m.id,
|
||||
owned_by: m.owned_by,
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 构造 /v1/models 的完整 URL
|
||||
fn build_models_url(base_url: &str, is_full_url: bool) -> Result<String, String> {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Err("Base URL is empty".to_string());
|
||||
}
|
||||
|
||||
if is_full_url {
|
||||
// 尝试从完整端点 URL 推导 API 根路径
|
||||
// 例如: https://proxy.example.com/v1/chat/completions → https://proxy.example.com/v1/models
|
||||
if let Some(idx) = trimmed.find("/v1/") {
|
||||
return Ok(format!("{}/v1/models", &trimmed[..idx]));
|
||||
}
|
||||
// 如果没有 /v1/ 路径,直接去掉最后一段路径
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
let root = &trimmed[..idx];
|
||||
if root.contains("://") && root.len() > root.find("://").unwrap() + 3 {
|
||||
return Ok(format!("{root}/v1/models"));
|
||||
}
|
||||
}
|
||||
return Err("Cannot derive models endpoint from full URL".to_string());
|
||||
}
|
||||
|
||||
// 常规情况: base_url 是 API 根路径
|
||||
// 如果已经包含 /v1 路径,直接追加 /models
|
||||
if trimmed.ends_with("/v1") {
|
||||
return Ok(format!("{trimmed}/models"));
|
||||
}
|
||||
|
||||
Ok(format!("{trimmed}/v1/models"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_basic() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.siliconflow.cn", false).unwrap(),
|
||||
"https://api.siliconflow.cn/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_trailing_slash() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_with_v1() {
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/v1", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_full_url() {
|
||||
assert_eq!(
|
||||
build_models_url("https://proxy.example.com/v1/chat/completions", true).unwrap(),
|
||||
"https://proxy.example.com/v1/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_empty() {
|
||||
assert!(build_models_url("", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response() {
|
||||
let json = r#"{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"openai"},{"id":"claude-3-sonnet","object":"model","owned_by":"anthropic"}]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data.len(), 2);
|
||||
assert_eq!(data[0].id, "gpt-4");
|
||||
assert_eq!(data[0].owned_by.as_deref(), Some("openai"));
|
||||
assert_eq!(data[1].id, "claude-3-sonnet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_no_owned_by() {
|
||||
let json = r#"{"object":"list","data":[{"id":"my-model","object":"model"}]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
let data = resp.data.unwrap();
|
||||
assert_eq!(data[0].id, "my-model");
|
||||
assert!(data[0].owned_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_empty_data() {
|
||||
let json = r#"{"object":"list","data":[]}"#;
|
||||
let resp: ModelsResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.data.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
+150
-44
@@ -1,6 +1,7 @@
|
||||
use crate::config::write_json_file;
|
||||
use crate::config::{atomic_write, write_json_file};
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
@@ -21,33 +22,41 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
|
||||
// ── Variant descriptor ─────────────────────────────────────────
|
||||
|
||||
pub struct OmoVariant {
|
||||
pub filename: &'static str,
|
||||
pub preferred_filename: &'static str,
|
||||
pub config_candidates: &'static [&'static str],
|
||||
pub category: &'static str,
|
||||
pub provider_prefix: &'static str,
|
||||
pub plugin_name: &'static str,
|
||||
pub plugin_prefix: &'static str,
|
||||
pub plugin_prefixes: &'static [&'static str],
|
||||
pub has_categories: bool,
|
||||
pub label: &'static str,
|
||||
pub import_label: &'static str,
|
||||
}
|
||||
|
||||
pub const STANDARD: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode.jsonc",
|
||||
preferred_filename: "oh-my-openagent.jsonc",
|
||||
config_candidates: &[
|
||||
"oh-my-openagent.jsonc",
|
||||
"oh-my-openagent.json",
|
||||
"oh-my-opencode.jsonc",
|
||||
"oh-my-opencode.json",
|
||||
],
|
||||
category: "omo",
|
||||
provider_prefix: "omo-",
|
||||
plugin_name: "oh-my-opencode@latest",
|
||||
plugin_prefix: "oh-my-opencode",
|
||||
plugin_name: "oh-my-openagent@latest",
|
||||
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
|
||||
has_categories: true,
|
||||
label: "OMO",
|
||||
import_label: "Imported",
|
||||
};
|
||||
|
||||
pub const SLIM: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode-slim.jsonc",
|
||||
preferred_filename: "oh-my-opencode-slim.jsonc",
|
||||
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
|
||||
category: "omo-slim",
|
||||
provider_prefix: "omo-slim-",
|
||||
plugin_name: "oh-my-opencode-slim@latest",
|
||||
plugin_prefix: "oh-my-opencode-slim",
|
||||
plugin_prefixes: &["oh-my-opencode-slim"],
|
||||
has_categories: false,
|
||||
label: "OMO Slim",
|
||||
import_label: "Imported Slim",
|
||||
@@ -60,22 +69,27 @@ pub struct OmoService;
|
||||
impl OmoService {
|
||||
// ── Path helpers ────────────────────────────────────────
|
||||
|
||||
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
|
||||
v.config_candidates
|
||||
.iter()
|
||||
.map(|name| base_dir.join(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
|
||||
Self::config_candidates(v, base_dir)
|
||||
.into_iter()
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||
get_opencode_dir().join(v.filename)
|
||||
let base_dir = get_opencode_dir();
|
||||
Self::find_existing_config_path(v, &base_dir)
|
||||
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
|
||||
}
|
||||
|
||||
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
return Ok(config_path);
|
||||
}
|
||||
|
||||
let json_path = config_path.with_extension("json");
|
||||
if json_path.exists() {
|
||||
return Ok(json_path);
|
||||
}
|
||||
|
||||
Err(AppError::OmoConfigNotFound)
|
||||
Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
|
||||
}
|
||||
|
||||
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
|
||||
@@ -120,44 +134,102 @@ impl OmoService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API (variant-parameterized) ─────────────────
|
||||
|
||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
log::info!("{} config file deleted: {config_path:?}", v.label);
|
||||
}
|
||||
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
|
||||
Ok(())
|
||||
fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
|
||||
let agents = provider.settings_config.get("agents").cloned();
|
||||
let categories = if v.has_categories {
|
||||
provider.settings_config.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let other_fields = provider.settings_config.get("otherFields").cloned();
|
||||
(agents, categories, other_fields)
|
||||
}
|
||||
|
||||
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||
let profile_data = current_omo.as_ref().map(|p| {
|
||||
let agents = p.settings_config.get("agents").cloned();
|
||||
let categories = if v.has_categories {
|
||||
p.settings_config.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||
(agents, categories, other_fields)
|
||||
});
|
||||
fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let merged = Self::build_config(v, profile_data.as_ref());
|
||||
std::fs::read(path)
|
||||
.map(Some)
|
||||
.map_err(|e| AppError::io(path, e))
|
||||
}
|
||||
|
||||
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
|
||||
match snapshot {
|
||||
Some(bytes) => atomic_write(path, bytes),
|
||||
None => {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_profile_config(
|
||||
v: &OmoVariant,
|
||||
profile_data: Option<&OmoProfileData>,
|
||||
) -> Result<(), AppError> {
|
||||
let merged = Self::build_config(v, profile_data);
|
||||
let config_path = Self::config_path(v);
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let previous_contents = Self::snapshot_config_file(&config_path)?;
|
||||
write_json_file(&config_path, &merged)?;
|
||||
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||
if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
|
||||
if let Err(rollback_err) =
|
||||
Self::restore_config_file(&config_path, previous_contents.as_deref())
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to roll back {} config after plugin sync error: {}",
|
||||
v.label,
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
log::info!("{} config written to {config_path:?}", v.label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Public API (variant-parameterized) ─────────────────
|
||||
|
||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||
let base_dir = get_opencode_dir();
|
||||
let mut deleted_paths = Vec::new();
|
||||
for config_path in Self::config_candidates(v, &base_dir) {
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
deleted_paths.push(config_path);
|
||||
}
|
||||
}
|
||||
if !deleted_paths.is_empty() {
|
||||
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
|
||||
}
|
||||
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||
let profile_data = current_omo
|
||||
.as_ref()
|
||||
.map(|provider| Self::profile_data_from_provider(provider, v));
|
||||
Self::write_profile_config(v, profile_data.as_ref())
|
||||
}
|
||||
|
||||
pub fn write_provider_config_to_file(
|
||||
provider: &Provider,
|
||||
v: &OmoVariant,
|
||||
) -> Result<(), AppError> {
|
||||
let profile_data = Self::profile_data_from_provider(provider, v);
|
||||
Self::write_profile_config(v, Some(&profile_data))
|
||||
}
|
||||
|
||||
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
||||
let mut result = Map::new();
|
||||
if let Some((agents, categories, other_fields)) = profile_data {
|
||||
@@ -451,4 +523,38 @@ mod tests {
|
||||
assert!(obj.contains_key("agents"));
|
||||
assert!(obj.contains_key("disabled_agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_existing_config_prefers_new_name_over_old() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
||||
let new_path = dir.path().join("oh-my-openagent.jsonc");
|
||||
|
||||
// Create both old and new files
|
||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
||||
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
|
||||
|
||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
||||
assert_eq!(
|
||||
found.unwrap(),
|
||||
new_path,
|
||||
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_existing_config_falls_back_to_old_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
||||
|
||||
// Only old file exists
|
||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
||||
|
||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
||||
assert_eq!(
|
||||
found.unwrap(),
|
||||
old_path,
|
||||
"When only the old config file exists, it should still be found"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
pub(crate) fn provider_exists_in_live_config(
|
||||
app_type: &AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
match app_type {
|
||||
AppType::OpenCode => crate::opencode_config::get_providers()
|
||||
.map(|providers| providers.contains_key(provider_id)),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_providers()
|
||||
.map(|providers| providers.contains_key(provider_id)),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_is_subset(target: &Value, source: &Value) -> bool {
|
||||
match source {
|
||||
Value::Object(source_map) => {
|
||||
@@ -727,10 +740,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||
return Err(AppError::Message(format!(
|
||||
"OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')",
|
||||
provider.id
|
||||
);
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -769,10 +782,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenClaw provider '{}' has invalid config structure, skipping write",
|
||||
return Err(AppError::Message(format!(
|
||||
"OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')",
|
||||
provider.id
|
||||
);
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -787,23 +800,30 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
/// Used for OpenCode and other additive mode applications.
|
||||
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let mut synced_count = 0usize;
|
||||
|
||||
for provider in providers.values() {
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.live_config_managed)
|
||||
== Some(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
provider.id
|
||||
);
|
||||
// Continue syncing other providers, don't abort
|
||||
continue;
|
||||
}
|
||||
synced_count += 1;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Synced {} {:?} providers to live config",
|
||||
providers.len(),
|
||||
app_type
|
||||
);
|
||||
log::info!("Synced {synced_count} {app_type:?} providers to live config");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1207,12 +1227,16 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
};
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(
|
||||
let mut provider = Provider::with_id(
|
||||
id.clone(),
|
||||
config.name.clone().unwrap_or_else(|| id.clone()),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
live_config_managed: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
@@ -1277,7 +1301,11 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
live_config_managed: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
|
||||
+1086
-143
File diff suppressed because it is too large
Load Diff
+458
-144
@@ -7,6 +7,7 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::switch_lock::SwitchLockManager;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
@@ -39,6 +40,12 @@ pub struct ProxyService {
|
||||
server: Arc<RwLock<Option<ProxyServer>>>,
|
||||
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
||||
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
||||
switch_locks: SwitchLockManager,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HotSwitchOutcome {
|
||||
pub logical_target_changed: bool,
|
||||
}
|
||||
|
||||
impl ProxyService {
|
||||
@@ -47,6 +54,7 @@ impl ProxyService {
|
||||
db,
|
||||
server: Arc::new(RwLock::new(None)),
|
||||
app_handle: Arc::new(RwLock::new(None)),
|
||||
switch_locks: SwitchLockManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +83,68 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {
|
||||
if !config.is_object() {
|
||||
*config = json!({});
|
||||
}
|
||||
|
||||
let root = config
|
||||
.as_object_mut()
|
||||
.expect("Claude config should be normalized to an object");
|
||||
let env = root.entry("env".to_string()).or_insert_with(|| json!({}));
|
||||
if !env.is_object() {
|
||||
*env = json!({});
|
||||
}
|
||||
|
||||
let env = env
|
||||
.as_object_mut()
|
||||
.expect("Claude env should be normalized to an object");
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(proxy_url));
|
||||
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sync_claude_live_from_provider_while_proxy_active(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let mut effective_settings = build_effective_settings_with_common_config(
|
||||
self.db.as_ref(),
|
||||
&AppType::Claude,
|
||||
provider,
|
||||
)
|
||||
.map_err(|e| format!("构建 claude 有效配置失败: {e}"))?;
|
||||
let (proxy_url, _) = self.build_proxy_urls().await?;
|
||||
|
||||
Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);
|
||||
self.write_claude_live(&effective_settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置 AppHandle(在应用初始化时调用)
|
||||
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
|
||||
futures::executor::block_on(async {
|
||||
@@ -841,41 +911,7 @@ impl ProxyService {
|
||||
|
||||
// Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
// 仅覆盖已存在的 Token 字段,避免新增字段导致用户困惑;
|
||||
// 若完全没有 Token 字段,则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
live_config["env"] = json!({
|
||||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
}
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
self.write_claude_live(&live_config)?;
|
||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
@@ -925,41 +961,7 @@ impl ProxyService {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let mut live_config = self.read_claude_live()?;
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
live_config["env"] = json!({
|
||||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
}
|
||||
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
self.write_claude_live(&live_config)?;
|
||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
@@ -1016,41 +1018,7 @@ impl ProxyService {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
live_config["env"] = json!({
|
||||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
}
|
||||
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
let _ = self.write_claude_live(&live_config);
|
||||
}
|
||||
}
|
||||
@@ -1100,6 +1068,11 @@ impl ProxyService {
|
||||
|
||||
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
|
||||
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
||||
self.restore_live_config_for_app_inner(app_type).await
|
||||
}
|
||||
|
||||
async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
||||
@@ -1159,6 +1132,15 @@ impl ProxyService {
|
||||
async fn restore_live_config_for_app_with_fallback(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
||||
self.restore_live_config_for_app_with_fallback_inner(app_type)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restore_live_config_for_app_with_fallback_inner(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), String> {
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
@@ -1487,6 +1469,17 @@ impl ProxyService {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
self.update_live_backup_from_provider_inner(app_type, provider)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 仅供已持有 per-app 切换锁的调用方使用。
|
||||
async fn update_live_backup_from_provider_inner(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
||||
@@ -1540,6 +1533,71 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hot_switch_provider(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<HotSwitchOutcome, String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
let provider = self
|
||||
.db
|
||||
.get_provider_by_id(provider_id, app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
|
||||
let logical_target_changed =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
|
||||
.map_err(|e| format!("读取当前供应商失败: {e}"))?
|
||||
.as_deref()
|
||||
!= Some(provider_id);
|
||||
|
||||
let has_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_enum.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
|
||||
.is_some();
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||
let should_sync_backup = has_backup || live_taken_over;
|
||||
|
||||
self.db
|
||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
if should_sync_backup {
|
||||
self.update_live_backup_from_provider_inner(app_type, &provider)
|
||||
.await?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Claude) {
|
||||
self.sync_claude_live_from_provider_while_proxy_active(&provider)
|
||||
.await?;
|
||||
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
|
||||
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(HotSwitchOutcome {
|
||||
logical_target_changed,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
||||
self.switch_locks.lock_for_app(app_type).await
|
||||
}
|
||||
|
||||
fn preserve_codex_mcp_servers_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
@@ -1607,47 +1665,13 @@ impl ProxyService {
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// 代理模式切换供应商(热切换):
|
||||
// - 更新 SSOT(数据库 is_current)
|
||||
// - 同步本地 settings(设备级 current_provider_*)
|
||||
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
let outcome = self.hot_switch_provider(app_type, provider_id).await?;
|
||||
|
||||
self.db
|
||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||
|
||||
// 同步本地 settings(设备级优先)
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
|
||||
let has_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_enum.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
|
||||
if has_backup || live_taken_over {
|
||||
self.update_live_backup_from_provider(app_type, &provider)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||
.await;
|
||||
}
|
||||
if outcome.logical_target_changed {
|
||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||
} else {
|
||||
log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
|
||||
}
|
||||
|
||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1924,6 +1948,7 @@ mod tests {
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
original_test_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
@@ -1931,14 +1956,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1954,6 +1982,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2193,6 +2226,287 @@ model = "gpt-5.1-codex"
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "a-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.a.example",
|
||||
"ANTHROPIC_MODEL": "claude-old"
|
||||
},
|
||||
"permissions": { "allow": ["Bash"] }
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "b-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.b.example",
|
||||
"ANTHROPIC_MODEL": "claude-new"
|
||||
},
|
||||
"permissions": { "allow": ["Read"] }
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup(
|
||||
"claude",
|
||||
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
|
||||
"ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_MODEL": "stale-model"
|
||||
},
|
||||
"permissions": { "allow": ["Bash"] }
|
||||
}))
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
service
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("hot switch provider");
|
||||
|
||||
let live = service.read_claude_live().expect("read live config");
|
||||
assert_eq!(
|
||||
live.get("permissions"),
|
||||
provider_b.settings_config.get("permissions"),
|
||||
"provider-derived live settings should be refreshed"
|
||||
);
|
||||
assert_eq!(
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER),
|
||||
"takeover token placeholder should be preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("http://127.0.0.1:15721"),
|
||||
"takeover proxy URL should remain active"
|
||||
);
|
||||
assert!(
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_MODEL"))
|
||||
.is_none(),
|
||||
"Claude model override fields should be removed in takeover mode"
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn hot_switch_provider_serializes_same_app_switches() {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_c = Provider::with_id(
|
||||
"c".to_string(),
|
||||
"C".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.save_provider("claude", &provider_c)
|
||||
.expect("save provider c");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup("claude", "{\"env\":{}}")
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
let guard = service.lock_switch_for_test("claude").await;
|
||||
let service_for_b = service.clone();
|
||||
let service_for_c = service.clone();
|
||||
|
||||
let switch_b = tokio::spawn(async move {
|
||||
service_for_b
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("switch to b")
|
||||
});
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
let switch_c = tokio::spawn(async move {
|
||||
service_for_c
|
||||
.hot_switch_provider("claude", "c")
|
||||
.await
|
||||
.expect("switch to c")
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
drop(guard);
|
||||
|
||||
let outcome_b = switch_b.await.expect("join switch b");
|
||||
let outcome_c = switch_c.await.expect("join switch c");
|
||||
assert!(outcome_b.logical_target_changed);
|
||||
assert!(outcome_c.logical_target_changed);
|
||||
|
||||
assert_eq!(
|
||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
||||
.expect("effective current"),
|
||||
Some("c".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
|
||||
Some("c")
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_current_provider("claude").expect("db current"),
|
||||
Some("c".to_string())
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn restore_waits_for_hot_switch_and_restores_latest_backup() {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup(
|
||||
"claude",
|
||||
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
service
|
||||
.write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } }))
|
||||
.expect("seed live file");
|
||||
|
||||
let guard = service.lock_switch_for_test("claude").await;
|
||||
let service_for_switch = service.clone();
|
||||
let service_for_restore = service.clone();
|
||||
|
||||
let switch_to_b = tokio::spawn(async move {
|
||||
service_for_switch
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("switch to b")
|
||||
});
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
let restore = tokio::spawn(async move {
|
||||
service_for_restore
|
||||
.restore_live_config_for_app_with_fallback(&AppType::Claude)
|
||||
.await
|
||||
.expect("restore claude live")
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
drop(guard);
|
||||
|
||||
let outcome = switch_to_b.await.expect("join switch");
|
||||
restore.await.expect("join restore");
|
||||
assert!(outcome.logical_target_changed);
|
||||
|
||||
assert_eq!(
|
||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
||||
.expect("effective current"),
|
||||
Some("b".to_string())
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
assert_eq!(
|
||||
service.read_claude_live().expect("read live"),
|
||||
provider_b.settings_config
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_applies_claude_common_config() {
|
||||
|
||||
@@ -88,6 +88,7 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
base_url_override: Option<String>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
// 合并供应商单独配置和全局配置
|
||||
@@ -100,6 +101,7 @@ impl StreamCheckService {
|
||||
provider,
|
||||
&effective_config,
|
||||
auth_override.clone(),
|
||||
base_url_override.clone(),
|
||||
claude_api_format_override.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -191,14 +193,18 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
base_url_override: Option<String>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
let start = Instant::now();
|
||||
let adapter = get_adapter(app_type);
|
||||
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?;
|
||||
let base_url = match base_url_override {
|
||||
Some(base_url) => base_url,
|
||||
None => adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?,
|
||||
};
|
||||
|
||||
let auth = auth_override
|
||||
.or_else(|| adapter.extract_auth(provider))
|
||||
@@ -364,6 +370,8 @@ impl StreamCheckService {
|
||||
let mut request_builder = client.post(&url);
|
||||
|
||||
if is_github_copilot {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
request_builder = request_builder
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
@@ -380,7 +388,13 @@ impl StreamCheckService {
|
||||
copilot_auth::COPILOT_INTEGRATION_ID,
|
||||
)
|
||||
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel");
|
||||
// 260401 新增copilot 的关键 headers
|
||||
.header("openai-intent", "conversation-agent")
|
||||
.header("x-initiator", "user")
|
||||
.header("x-interaction-type", "conversation-agent")
|
||||
.header("x-vscode-user-agent-library-version", "electron-fetch")
|
||||
.header("x-request-id", &request_id)
|
||||
.header("x-agent-task-id", &request_id);
|
||||
} else if is_openai_chat || is_openai_responses {
|
||||
// OpenAI-compatible targets: Bearer auth + SSE headers only
|
||||
request_builder = request_builder
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user