mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 21:54:58 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f426a68117 | |||
| 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 |
@@ -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>
|
||||
|
||||
@@ -292,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>
|
||||
@@ -338,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
|
||||
|
||||
|
||||
+8
-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>
|
||||
|
||||
@@ -189,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>
|
||||
|
||||
@@ -292,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>
|
||||
@@ -338,7 +334,7 @@ flatpak run com.ccswitch.desktop
|
||||
- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート
|
||||
- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期
|
||||
- **ProxyService**: ローカル Proxy モードのホットスイッチとフォーマット変換
|
||||
- **SessionManager**: Claude Code の会話履歴閲覧
|
||||
- **SessionManager**: 対応する全アプリの会話履歴閲覧
|
||||
- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション
|
||||
- **SpeedtestService**: API エンドポイントの遅延計測
|
||||
|
||||
|
||||
+6
-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>
|
||||
|
||||
@@ -295,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>
|
||||
@@ -341,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 讨论。
|
||||
@@ -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
|
||||
|
||||
@@ -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: `{}`).
|
||||
|
||||
@@ -1100,21 +1100,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()
|
||||
};
|
||||
|
||||
|
||||
@@ -36,9 +36,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 +48,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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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\": {}"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,9 @@ pub fn get_opencode_dir() -> PathBuf {
|
||||
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 {
|
||||
|
||||
@@ -283,6 +283,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")]
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
@@ -133,6 +134,68 @@ impl OmoService {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
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)?;
|
||||
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> {
|
||||
@@ -153,28 +216,18 @@ impl OmoService {
|
||||
|
||||
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)
|
||||
});
|
||||
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())
|
||||
}
|
||||
|
||||
let merged = Self::build_config(v, profile_data.as_ref());
|
||||
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))?;
|
||||
}
|
||||
|
||||
write_json_file(&config_path, &merged)?;
|
||||
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||
log::info!("{} config written to {config_path:?}", v.label);
|
||||
Ok(())
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1084
-121
File diff suppressed because it is too large
Load Diff
+178
-105
@@ -83,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 {
|
||||
@@ -849,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}");
|
||||
}
|
||||
@@ -933,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}");
|
||||
}
|
||||
@@ -1024,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);
|
||||
}
|
||||
}
|
||||
@@ -1614,6 +1574,8 @@ impl ProxyService {
|
||||
.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}");
|
||||
}
|
||||
@@ -1986,6 +1948,7 @@ mod tests {
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
original_test_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
@@ -1993,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2016,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2255,6 +2226,108 @@ 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() {
|
||||
|
||||
+41
-6
@@ -533,8 +533,14 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProvider = async (provider: Provider) => {
|
||||
await updateProvider(provider);
|
||||
const handleEditProvider = async ({
|
||||
provider,
|
||||
originalId,
|
||||
}: {
|
||||
provider: Provider;
|
||||
originalId?: string;
|
||||
}) => {
|
||||
await updateProvider(provider, originalId);
|
||||
setEditingProvider(null);
|
||||
};
|
||||
|
||||
@@ -571,7 +577,7 @@ function App() {
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const generateUniqueOpencodeKey = (
|
||||
const generateUniqueProviderCopyKey = (
|
||||
originalKey: string,
|
||||
existingKeys: string[],
|
||||
): string => {
|
||||
@@ -594,6 +600,7 @@ function App() {
|
||||
|
||||
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> & {
|
||||
providerKey?: string;
|
||||
addToLive?: boolean;
|
||||
} = {
|
||||
name: `${provider.name} copy`,
|
||||
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
|
||||
@@ -607,12 +614,40 @@ function App() {
|
||||
iconColor: provider.iconColor,
|
||||
};
|
||||
|
||||
if (activeApp === "opencode") {
|
||||
const existingKeys = Object.keys(providers);
|
||||
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
||||
if (activeApp === "opencode" || activeApp === "openclaw") {
|
||||
let liveProviderIds: string[] = [];
|
||||
try {
|
||||
liveProviderIds =
|
||||
activeApp === "opencode"
|
||||
? await queryClient.ensureQueryData({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
||||
})
|
||||
: await queryClient.ensureQueryData({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[App] Failed to load live provider IDs for duplication",
|
||||
error,
|
||||
);
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
toast.error(
|
||||
t("provider.duplicateLiveIdsLoadFailed", {
|
||||
defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试",
|
||||
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const existingKeys = Array.from(
|
||||
new Set([...Object.keys(providers), ...liveProviderIds]),
|
||||
);
|
||||
duplicatedProvider.providerKey = generateUniqueProviderCopyKey(
|
||||
provider.id,
|
||||
existingKeys,
|
||||
);
|
||||
duplicatedProvider.addToLive = false;
|
||||
}
|
||||
|
||||
if (provider.sortIndex !== undefined) {
|
||||
|
||||
@@ -873,7 +873,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) => setScript({ ...script, code: value })}
|
||||
onChange={(value) =>
|
||||
setScript((prev) => ({ ...prev, code: value }))
|
||||
}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
|
||||
@@ -14,7 +14,10 @@ interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
provider: Provider | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (provider: Provider) => Promise<void> | void;
|
||||
onSubmit: (payload: {
|
||||
provider: Provider;
|
||||
originalId?: string;
|
||||
}) => Promise<void> | void;
|
||||
appId: AppId;
|
||||
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
|
||||
}
|
||||
@@ -165,9 +168,15 @@ export function EditProviderDialog({
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const nextProviderId =
|
||||
(appId === "opencode" || appId === "openclaw") &&
|
||||
values.providerKey?.trim()
|
||||
? values.providerKey.trim()
|
||||
: provider.id;
|
||||
|
||||
const updatedProvider: Provider = {
|
||||
...provider,
|
||||
id: nextProviderId,
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
@@ -179,10 +188,13 @@ export function EditProviderDialog({
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
|
||||
await onSubmit(updatedProvider);
|
||||
await onSubmit({
|
||||
provider: updatedProvider,
|
||||
originalId: provider.id,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onSubmit, onOpenChange, provider],
|
||||
[appId, onSubmit, onOpenChange, provider],
|
||||
);
|
||||
|
||||
if (!provider || !initialData) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi, type AppId } from "@/lib/api";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ProviderMeta,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
normalizePricingSource,
|
||||
} from "./helpers/opencodeFormUtils";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -427,10 +429,12 @@ export function ProviderForm({
|
||||
preset,
|
||||
}));
|
||||
}
|
||||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||||
id: `claude-${index}`,
|
||||
preset,
|
||||
}));
|
||||
return providerPresets
|
||||
.filter((p) => !p.hidden)
|
||||
.map<PresetEntry>((preset, index) => ({
|
||||
id: `claude-${index}`,
|
||||
preset,
|
||||
}));
|
||||
}, [appId]);
|
||||
|
||||
const {
|
||||
@@ -577,6 +581,15 @@ export function ProviderForm({
|
||||
existingOpencodeKeys,
|
||||
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
|
||||
|
||||
const {
|
||||
data: opencodeLiveProviderIds = [],
|
||||
isLoading: isOpencodeLiveProviderIdsLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
|
||||
enabled: appId === "opencode" && !isAnyOmoCategory,
|
||||
});
|
||||
|
||||
const opencodeForm = useOpencodeFormState({
|
||||
initialData,
|
||||
appId,
|
||||
@@ -605,6 +618,78 @@ export function ProviderForm({
|
||||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
getSettingsConfig: () => form.getValues("settingsConfig"),
|
||||
});
|
||||
const {
|
||||
data: openclawLiveProviderIds = [],
|
||||
isLoading: isOpenclawLiveProviderIdsLoading,
|
||||
} = useOpenClawLiveProviderIds(appId === "openclaw");
|
||||
|
||||
const additiveExistingProviderKeys = useMemo(() => {
|
||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...existingOpencodeKeys, ...opencodeLiveProviderIds].filter(
|
||||
(key) => key !== providerId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (appId === "openclaw") {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
...openclawForm.existingOpenclawKeys,
|
||||
...openclawLiveProviderIds,
|
||||
].filter((key) => key !== providerId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [
|
||||
appId,
|
||||
existingOpencodeKeys,
|
||||
isAnyOmoCategory,
|
||||
openclawForm.existingOpenclawKeys,
|
||||
openclawLiveProviderIds,
|
||||
opencodeLiveProviderIds,
|
||||
providerId,
|
||||
]);
|
||||
|
||||
const isProviderKeyLockStateLoading = useMemo(() => {
|
||||
if (!isEditMode) return false;
|
||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
||||
return isOpencodeLiveProviderIdsLoading;
|
||||
}
|
||||
if (appId === "openclaw") {
|
||||
return isOpenclawLiveProviderIdsLoading;
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
appId,
|
||||
isAnyOmoCategory,
|
||||
isEditMode,
|
||||
isOpenclawLiveProviderIdsLoading,
|
||||
isOpencodeLiveProviderIdsLoading,
|
||||
]);
|
||||
|
||||
const isProviderKeyLocked = useMemo(() => {
|
||||
if (!isEditMode || !providerId) return false;
|
||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
||||
return opencodeLiveProviderIds.includes(providerId);
|
||||
}
|
||||
if (appId === "openclaw") {
|
||||
return openclawLiveProviderIds.includes(providerId);
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
appId,
|
||||
isAnyOmoCategory,
|
||||
isEditMode,
|
||||
openclawLiveProviderIds,
|
||||
opencodeLiveProviderIds,
|
||||
providerId,
|
||||
]);
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
@@ -641,9 +726,17 @@ export function ProviderForm({
|
||||
toast.error(t("opencode.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (isProviderKeyLockStateLoading) {
|
||||
toast.error(
|
||||
t("providerForm.providerKeyStatusLoading", {
|
||||
defaultValue: "正在加载供应商标识状态,请稍后再试",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isEditMode &&
|
||||
existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey)
|
||||
!isProviderKeyLocked &&
|
||||
additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
|
||||
) {
|
||||
toast.error(t("opencode.providerKeyDuplicate"));
|
||||
return;
|
||||
@@ -665,11 +758,17 @@ export function ProviderForm({
|
||||
toast.error(t("openclaw.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (isProviderKeyLockStateLoading) {
|
||||
toast.error(
|
||||
t("providerForm.providerKeyStatusLoading", {
|
||||
defaultValue: "正在加载供应商标识状态,请稍后再试",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isEditMode &&
|
||||
openclawForm.existingOpenclawKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
)
|
||||
!isProviderKeyLocked &&
|
||||
additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
|
||||
) {
|
||||
toast.error(t("openclaw.providerKeyDuplicate"));
|
||||
return;
|
||||
@@ -1253,12 +1352,14 @@ export function ProviderForm({
|
||||
)
|
||||
}
|
||||
placeholder={t("opencode.providerKeyPlaceholder")}
|
||||
disabled={isEditMode}
|
||||
disabled={
|
||||
isProviderKeyLocked || isProviderKeyLockStateLoading
|
||||
}
|
||||
className={
|
||||
(existingOpencodeKeys.includes(
|
||||
(additiveExistingProviderKeys.includes(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
) &&
|
||||
!isEditMode) ||
|
||||
!isProviderKeyLocked) ||
|
||||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
@@ -1267,10 +1368,10 @@ export function ProviderForm({
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{existingOpencodeKeys.includes(
|
||||
{additiveExistingProviderKeys.includes(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
) &&
|
||||
!isEditMode && (
|
||||
!isProviderKeyLocked && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("opencode.providerKeyDuplicate")}
|
||||
</p>
|
||||
@@ -1284,16 +1385,21 @@ export function ProviderForm({
|
||||
</p>
|
||||
)}
|
||||
{!(
|
||||
existingOpencodeKeys.includes(
|
||||
additiveExistingProviderKeys.includes(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
) && !isEditMode
|
||||
) && !isProviderKeyLocked
|
||||
) &&
|
||||
(opencodeForm.opencodeProviderKey.trim() === "" ||
|
||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("opencode.providerKeyHint")}
|
||||
{isProviderKeyLocked
|
||||
? t("opencode.providerKeyLockedHint", {
|
||||
defaultValue:
|
||||
"该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
})
|
||||
: t("opencode.providerKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1312,12 +1418,14 @@ export function ProviderForm({
|
||||
)
|
||||
}
|
||||
placeholder={t("openclaw.providerKeyPlaceholder")}
|
||||
disabled={isEditMode}
|
||||
disabled={
|
||||
isProviderKeyLocked || isProviderKeyLockStateLoading
|
||||
}
|
||||
className={
|
||||
(openclawForm.existingOpenclawKeys.includes(
|
||||
(additiveExistingProviderKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
) &&
|
||||
!isEditMode) ||
|
||||
!isProviderKeyLocked) ||
|
||||
(openclawForm.openclawProviderKey.trim() !== "" &&
|
||||
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
openclawForm.openclawProviderKey,
|
||||
@@ -1326,10 +1434,10 @@ export function ProviderForm({
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{openclawForm.existingOpenclawKeys.includes(
|
||||
{additiveExistingProviderKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
) &&
|
||||
!isEditMode && (
|
||||
!isProviderKeyLocked && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("openclaw.providerKeyDuplicate")}
|
||||
</p>
|
||||
@@ -1343,16 +1451,21 @@ export function ProviderForm({
|
||||
</p>
|
||||
)}
|
||||
{!(
|
||||
openclawForm.existingOpenclawKeys.includes(
|
||||
additiveExistingProviderKeys.includes(
|
||||
openclawForm.openclawProviderKey,
|
||||
) && !isEditMode
|
||||
) && !isProviderKeyLocked
|
||||
) &&
|
||||
(openclawForm.openclawProviderKey.trim() === "" ||
|
||||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
|
||||
openclawForm.openclawProviderKey,
|
||||
)) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.providerKeyHint")}
|
||||
{isProviderKeyLocked
|
||||
? t("openclaw.providerKeyLockedHint", {
|
||||
defaultValue:
|
||||
"该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
})
|
||||
: t("openclaw.providerKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -468,9 +468,12 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-950/60"
|
||||
aria-label={t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})}
|
||||
aria-label={t(
|
||||
"sessionManager.exitBatchModeTooltip",
|
||||
{
|
||||
defaultValue: "退出批量管理",
|
||||
},
|
||||
)}
|
||||
onClick={exitSelectionMode}
|
||||
>
|
||||
<CheckSquare className="size-3.5" />
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ScrollText,
|
||||
HardDriveDownload,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -43,7 +42,6 @@ import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
||||
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -191,14 +189,16 @@ export function SettingsPage({
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-6 mb-6 glass rounded-lg">
|
||||
<TabsList className="grid w-full grid-cols-5 mb-6 glass rounded-lg">
|
||||
<TabsTrigger value="general">
|
||||
{t("settings.tabGeneral")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
|
||||
{/* HIDDEN: Copilot auth tab temporarily disabled - 用户反馈消耗过快
|
||||
<TabsTrigger value="auth">
|
||||
{t("settings.tabAuth", { defaultValue: "认证" })}
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
@@ -254,6 +254,7 @@ export function SettingsPage({
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
{/* HIDDEN: Copilot auth tab temporarily disabled - 用户反馈消耗过快
|
||||
<TabsContent value="auth" className="space-y-6 mt-0 pb-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -281,6 +282,7 @@ export function SettingsPage({
|
||||
<AuthCenterPanel />
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
*/}
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
||||
{settings ? (
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface ProviderPreset {
|
||||
|
||||
// 是否需要 OAuth 认证(而非 API Key)
|
||||
requiresOAuth?: boolean;
|
||||
|
||||
// 是否在 UI 中隐藏该预设(预设仍存在,仅不在列表中显示)
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
@@ -704,6 +707,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
requiresOAuth: true,
|
||||
icon: "github",
|
||||
iconColor: "#000000",
|
||||
hidden: true, // 临时隐藏:用户反馈消耗速度过快
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
addToLive?: boolean;
|
||||
},
|
||||
) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
@@ -120,8 +121,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
|
||||
// 更新供应商
|
||||
const updateProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
await updateProviderMutation.mutateAsync(provider);
|
||||
async (provider: Provider, originalId?: string) => {
|
||||
await updateProviderMutation.mutateAsync({ provider, originalId });
|
||||
|
||||
// 更新托盘菜单(失败不影响主操作)
|
||||
try {
|
||||
|
||||
@@ -933,7 +933,8 @@
|
||||
"modelsRequired": "Please add at least one model",
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
@@ -1392,7 +1393,8 @@
|
||||
"backupCreated": "Backup created: {{path}}",
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
|
||||
@@ -933,7 +933,8 @@
|
||||
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
||||
@@ -1392,7 +1393,8 @@
|
||||
"backupCreated": "バックアップを作成しました: {{path}}",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
|
||||
@@ -933,7 +933,8 @@
|
||||
"modelsRequired": "请至少添加一个模型配置",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
@@ -1392,7 +1393,8 @@
|
||||
"backupCreated": "已创建备份:{{path}}",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
|
||||
@@ -34,12 +34,24 @@ export const providersApi = {
|
||||
return await invoke("get_current_provider", { app: appId });
|
||||
},
|
||||
|
||||
async add(provider: Provider, appId: AppId): Promise<boolean> {
|
||||
return await invoke("add_provider", { provider, app: appId });
|
||||
async add(
|
||||
provider: Provider,
|
||||
appId: AppId,
|
||||
addToLive?: boolean,
|
||||
): Promise<boolean> {
|
||||
return await invoke("add_provider", { provider, app: appId, addToLive });
|
||||
},
|
||||
|
||||
async update(provider: Provider, appId: AppId): Promise<boolean> {
|
||||
return await invoke("update_provider", { provider, app: appId });
|
||||
async update(
|
||||
provider: Provider,
|
||||
appId: AppId,
|
||||
originalId?: string,
|
||||
): Promise<boolean> {
|
||||
return await invoke("update_provider", {
|
||||
provider,
|
||||
app: appId,
|
||||
originalId,
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string, appId: AppId): Promise<boolean> {
|
||||
|
||||
@@ -15,7 +15,10 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
providerInput: Omit<Provider, "id"> & { providerKey?: string },
|
||||
providerInput: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
addToLive?: boolean;
|
||||
},
|
||||
) => {
|
||||
let id: string;
|
||||
|
||||
@@ -36,7 +39,7 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
id = generateUUID();
|
||||
}
|
||||
|
||||
const { providerKey: _providerKey, ...rest } = providerInput;
|
||||
const { providerKey: _providerKey, addToLive, ...rest } = providerInput;
|
||||
|
||||
const newProvider: Provider = {
|
||||
...rest,
|
||||
@@ -45,7 +48,7 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
};
|
||||
delete (newProvider as any).providerKey;
|
||||
|
||||
await providersApi.add(newProvider, appId);
|
||||
await providersApi.add(newProvider, appId, addToLive);
|
||||
return newProvider;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -107,8 +110,14 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (provider: Provider) => {
|
||||
await providersApi.update(provider, appId);
|
||||
mutationFn: async ({
|
||||
provider,
|
||||
originalId,
|
||||
}: {
|
||||
provider: Provider;
|
||||
originalId?: string;
|
||||
}) => {
|
||||
await providersApi.update(provider, appId, originalId);
|
||||
return provider;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -169,7 +169,10 @@ describe("useProviderActions", () => {
|
||||
await result.current.updateProvider(provider);
|
||||
});
|
||||
|
||||
expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider);
|
||||
expect(updateProviderMutateAsync).toHaveBeenCalledWith({
|
||||
provider,
|
||||
originalId: undefined,
|
||||
});
|
||||
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { Suspense, type ComponentType } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { resetProviderState } from "../msw/state";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import {
|
||||
resetProviderState,
|
||||
setCurrentProviderId,
|
||||
setLiveProviderIds,
|
||||
setProviders,
|
||||
} from "../msw/state";
|
||||
import { emitTauriEvent } from "../msw/tauriMocks";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
@@ -75,8 +81,11 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({
|
||||
<button
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
...provider,
|
||||
name: `${provider.name}-edited`,
|
||||
provider: {
|
||||
...provider,
|
||||
name: `${provider.name}-edited`,
|
||||
},
|
||||
originalId: provider.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -114,6 +123,7 @@ vi.mock("@/components/AppSwitcher", () => ({
|
||||
<span>{activeApp}</span>
|
||||
<button onClick={() => onSwitch("claude")}>switch-claude</button>
|
||||
<button onClick={() => onSwitch("codex")}>switch-codex</button>
|
||||
<button onClick={() => onSwitch("openclaw")}>switch-openclaw</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -230,4 +240,95 @@ describe("App integration with MSW", () => {
|
||||
expect(toastErrorMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {
|
||||
setProviders("openclaw", {
|
||||
deepseek: {
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
apiKey: "test-key",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
category: "custom",
|
||||
sortIndex: 0,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
setCurrentProviderId("openclaw", "deepseek");
|
||||
setLiveProviderIds("openclaw", ["deepseek-copy"]);
|
||||
|
||||
const { default: App } = await import("@/App");
|
||||
renderApp(App);
|
||||
|
||||
fireEvent.click(screen.getByText("switch-openclaw"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||
"deepseek",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
const providerList = screen.getByTestId("provider-list").textContent;
|
||||
expect(providerList).toContain("deepseek-copy-2");
|
||||
expect(providerList).toContain("DeepSeek copy");
|
||||
});
|
||||
|
||||
expect(toastErrorMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Provider key is required for openclaw"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows toast when duplicate cannot load live provider ids", async () => {
|
||||
setProviders("openclaw", {
|
||||
deepseek: {
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
apiKey: "test-key",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
category: "custom",
|
||||
sortIndex: 0,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
setCurrentProviderId("openclaw", "deepseek");
|
||||
|
||||
const liveIdsSpy = vi
|
||||
.spyOn(providersApi, "getOpenClawLiveProviderIds")
|
||||
.mockRejectedValueOnce(new Error("broken config"));
|
||||
|
||||
const { default: App } = await import("@/App");
|
||||
renderApp(App);
|
||||
|
||||
fireEvent.click(screen.getByText("switch-openclaw"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||
"deepseek",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("读取配置中的供应商标识失败"),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("provider-list").textContent).not.toContain(
|
||||
"deepseek-copy",
|
||||
);
|
||||
|
||||
liveIdsSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deleteProvider,
|
||||
deleteSession,
|
||||
getCurrentProviderId,
|
||||
getLiveProviderIds,
|
||||
getSessionMessages,
|
||||
getProviders,
|
||||
listProviders,
|
||||
@@ -67,6 +68,20 @@ export const handlers = [
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_opencode_live_provider_ids`, () =>
|
||||
success(getLiveProviderIds("opencode")),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_openclaw_live_provider_ids`, () =>
|
||||
success(getLiveProviderIds("openclaw")),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () =>
|
||||
success({ primary: null, fallback: [] }),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/scan_openclaw_config_health`, () => success([])),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => {
|
||||
const { id, app } = await withJson<{ id: string; app: AppId }>(request);
|
||||
const providers = listProviders(app);
|
||||
@@ -197,6 +212,8 @@ export const handlers = [
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/check_env_conflicts`, () => success([])),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {
|
||||
const { settings } = await withJson<{ settings: Settings }>(request);
|
||||
setSettings(settings);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
|
||||
type CurrentProviderState = Record<AppId, string>;
|
||||
type McpConfigState = Record<AppId, Record<string, McpServer>>;
|
||||
type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>;
|
||||
|
||||
const createDefaultProviders = (): ProvidersByApp => ({
|
||||
claude: {
|
||||
@@ -77,6 +78,10 @@ const createDefaultCurrent = (): CurrentProviderState => ({
|
||||
|
||||
let providers = createDefaultProviders();
|
||||
let current = createDefaultCurrent();
|
||||
let liveProviderIds: LiveProviderIdsByApp = {
|
||||
opencode: [],
|
||||
openclaw: [],
|
||||
};
|
||||
let settingsState: Settings = {
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
@@ -184,6 +189,10 @@ const cloneProviders = (value: ProvidersByApp) =>
|
||||
export const resetProviderState = () => {
|
||||
providers = createDefaultProviders();
|
||||
current = createDefaultCurrent();
|
||||
liveProviderIds = {
|
||||
opencode: [],
|
||||
openclaw: [],
|
||||
};
|
||||
sessionsState = createDefaultSessions();
|
||||
sessionMessagesState = createDefaultSessionMessages();
|
||||
settingsState = {
|
||||
@@ -243,6 +252,17 @@ export const getProviders = (appType: AppId) =>
|
||||
|
||||
export const getCurrentProviderId = (appType: AppId) => current[appType] ?? "";
|
||||
|
||||
export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [
|
||||
...liveProviderIds[appType],
|
||||
];
|
||||
|
||||
export const setLiveProviderIds = (
|
||||
appType: "opencode" | "openclaw",
|
||||
ids: string[],
|
||||
) => {
|
||||
liveProviderIds[appType] = [...ids];
|
||||
};
|
||||
|
||||
export const setCurrentProviderId = (appType: AppId, providerId: string) => {
|
||||
current[appType] = providerId;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user