mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Compare commits
34 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 | |||
| 854266e3c4 | |||
| af8f907467 | |||
| 6a083cdd1c | |||
| c68476d0ea | |||
| 96a0a37331 | |||
| 8e2ffbc845 | |||
| 4f7ea76347 |
@@ -0,0 +1,2 @@
|
||||
custom:
|
||||
- https://github.com/farion1231/cc-switch/blob/main/README_ZH.md#%E2%9D%A4%EF%B8%8F%E8%B5%9E%E5%8A%A9%E5%95%86
|
||||
@@ -0,0 +1,97 @@
|
||||
name: "🐛 Bug Report / Bug 报告"
|
||||
description: |
|
||||
Report errors or unexpected behavior.
|
||||
报告错误或异常行为。
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have read the [FAQ](https://github.com/farion1231/cc-switch#faq) section in README.
|
||||
我已阅读 README 中的[常见问题](https://github.com/farion1231/cc-switch#常见问题)。
|
||||
required: true
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: CC Switch Version / 版本号
|
||||
description: |
|
||||
See the Settings page in the app.
|
||||
查看应用设置页面中的版本号。
|
||||
placeholder: "e.g. 3.11.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Operating System / 操作系统
|
||||
multiple: false
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Related App / 涉及应用
|
||||
description: |
|
||||
Which app is affected?
|
||||
涉及哪个应用?
|
||||
multiple: true
|
||||
options:
|
||||
- Claude Code
|
||||
- Codex
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to Reproduce / 重现步骤
|
||||
description: |
|
||||
Please describe the steps to reproduce the bug. Screenshots and logs are helpful.
|
||||
请描述重现步骤。截图和日志会很有帮助。
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click on ...
|
||||
3. See error ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior / 期望行为
|
||||
description: |
|
||||
What did you expect to happen?
|
||||
你期望发生什么?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual Behavior / 实际行为
|
||||
description: |
|
||||
What actually happened?
|
||||
实际发生了什么?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: |
|
||||
Any other information, screenshots, or logs.
|
||||
其他信息、截图或日志。
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,12 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "Security Vulnerabilities / 安全漏洞"
|
||||
url: "https://github.com/farion1231/cc-switch/security/advisories/new"
|
||||
about: |
|
||||
Report security vulnerabilities through GitHub Security Advisories.
|
||||
请通过 GitHub 安全公告报告安全漏洞。
|
||||
- name: "Discussions / 讨论区"
|
||||
url: "https://github.com/farion1231/cc-switch/discussions"
|
||||
about: |
|
||||
General discussions and community help.
|
||||
一般讨论和社区帮助。
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "📖 Documentation Issue / 文档问题"
|
||||
description: |
|
||||
Report documentation errors, outdated content, or missing information.
|
||||
报告文档错误、过时内容或缺失信息。
|
||||
labels:
|
||||
- documentation
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Language / 语言
|
||||
description: |
|
||||
Which language version of the docs?
|
||||
涉及哪个语言版本的文档?
|
||||
multiple: true
|
||||
options:
|
||||
- English
|
||||
- 中文
|
||||
- 日本語
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Document Location / 文档位置
|
||||
description: |
|
||||
Which file or section is affected? Provide a link or file path.
|
||||
涉及哪个文件或章节?请提供链接或文件路径。
|
||||
placeholder: "e.g. README.md#faq or docs/user-manual/zh/..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Issue Type / 问题类型
|
||||
options:
|
||||
- "Typo or grammar / 拼写或语法错误"
|
||||
- "Outdated content / 内容过时"
|
||||
- "Missing information / 信息缺失"
|
||||
- "Translation issue / 翻译问题"
|
||||
- "Other / 其他"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description / 描述
|
||||
description: |
|
||||
Describe the documentation issue.
|
||||
描述文档问题。
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,75 @@
|
||||
name: "✨ Feature Request / 功能请求"
|
||||
description: |
|
||||
Propose a new feature or improvement.
|
||||
提出新功能或改进建议。
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have read the [FAQ](https://github.com/farion1231/cc-switch#faq) section in README.
|
||||
我已阅读 README 中的[常见问题](https://github.com/farion1231/cc-switch#常见问题)。
|
||||
required: true
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Related App / 涉及应用
|
||||
description: |
|
||||
Which app is this feature for?
|
||||
该功能涉及哪个应用?
|
||||
multiple: true
|
||||
options:
|
||||
- Claude Code
|
||||
- Codex
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- General / 通用
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem or Motivation / 问题或动机
|
||||
description: |
|
||||
Is this request related to a problem? Describe the scenario.
|
||||
这个请求是否与某个问题相关?请描述使用场景。
|
||||
placeholder: |
|
||||
I was trying to ... and found that ...
|
||||
我在尝试……的时候,发现……
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Solution / 建议方案
|
||||
description: |
|
||||
Describe the solution you'd like.
|
||||
描述你希望的解决方案。
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context / 补充信息
|
||||
description: |
|
||||
Any other context, mockups, or screenshots.
|
||||
其他背景信息、设计稿或截图。
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Contribution / 参与贡献
|
||||
options:
|
||||
- label: |
|
||||
I am interested in contributing to this feature.
|
||||
我有兴趣参与开发此功能。
|
||||
required: false
|
||||
@@ -0,0 +1,64 @@
|
||||
name: "❓ Question / 提问"
|
||||
description: |
|
||||
Ask a question about usage or configuration.
|
||||
询问使用或配置相关问题。
|
||||
labels:
|
||||
- question
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks / 自检
|
||||
options:
|
||||
- label: |
|
||||
I have read the [FAQ](https://github.com/farion1231/cc-switch#faq) section in README.
|
||||
我已阅读 README 中的[常见问题](https://github.com/farion1231/cc-switch#常见问题)。
|
||||
required: true
|
||||
- label: |
|
||||
I have searched for [existing issues](https://github.com/farion1231/cc-switch/issues), including closed ones.
|
||||
我已搜索过[已有的 Issue](https://github.com/farion1231/cc-switch/issues),包括已关闭的。
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Related App / 涉及应用
|
||||
description: |
|
||||
Which app is your question about?
|
||||
你的问题涉及哪个应用?
|
||||
multiple: true
|
||||
options:
|
||||
- Claude Code
|
||||
- Codex
|
||||
- Gemini CLI
|
||||
- OpenCode
|
||||
- OpenClaw
|
||||
- General / 通用
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Your Question / 你的问题
|
||||
description: |
|
||||
Describe your question clearly.
|
||||
请清晰地描述你的问题。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What Have You Tried / 你已经尝试过什么
|
||||
description: |
|
||||
Describe what you've already tried or researched.
|
||||
描述你已经尝试过或查阅过的内容。
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment / 环境信息
|
||||
description: |
|
||||
OS, CC Switch version, and any relevant details.
|
||||
操作系统、CC Switch 版本及其他相关信息。
|
||||
placeholder: "e.g. macOS 15.4, CC Switch 3.11.1"
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,39 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
frontend-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "backend"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
cargo-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "actions"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
@@ -0,0 +1,25 @@
|
||||
## Summary / 概述
|
||||
|
||||
<!-- Briefly describe what this PR does and why. / 简要描述这个 PR 做了什么以及为什么。 -->
|
||||
|
||||
## Related Issue / 关联 Issue
|
||||
|
||||
<!-- Link the related issue. Use "Fixes #123" to auto-close it when merged. -->
|
||||
<!-- 关联相关 Issue。使用 "Fixes #123" 可在合并时自动关闭。 -->
|
||||
|
||||
Fixes #
|
||||
|
||||
## Screenshots / 截图
|
||||
|
||||
<!-- If applicable, add before/after screenshots. / 如有需要,请添加修改前后的截图。 -->
|
||||
|
||||
| Before / 修改前 | After / 修改后 |
|
||||
|-----------------|---------------|
|
||||
| | |
|
||||
|
||||
## Checklist / 检查清单
|
||||
|
||||
- [ ] `pnpm typecheck` passes / 通过 TypeScript 类型检查
|
||||
- [ ] `pnpm format:check` passes / 通过代码格式检查
|
||||
- [ ] `cargo clippy` passes (if Rust code changed) / 通过 Clippy 检查(如修改了 Rust 代码)
|
||||
- [ ] Updated i18n files if user-facing text changed / 如修改了用户可见文本,已更新国际化文件
|
||||
@@ -0,0 +1,99 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend Checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: TypeScript type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test:unit
|
||||
|
||||
backend:
|
||||
name: Backend Checks
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install Linux system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config libssl-dev \
|
||||
libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev
|
||||
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
|
||||
sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Cache Cargo registry and build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Create frontend dist placeholder
|
||||
run: mkdir -p dist
|
||||
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --check --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --manifest-path src-tauri/Cargo.toml
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -490,7 +490,7 @@ jobs:
|
||||
ls -la release-assets/*.sig || echo "No signatures found"
|
||||
|
||||
- name: Upload release artifacts to workflow
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-assets-${{ runner.os }}-${{ matrix.arch || runner.arch }}
|
||||
path: release-assets/*
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download built release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-assets-*
|
||||
path: release-assets
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Close Stale Issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 00:00 UTC
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
# --- Timing ---
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
|
||||
# --- Stale label ---
|
||||
stale-issue-label: 'stale'
|
||||
|
||||
# --- Messages (bilingual) ---
|
||||
stale-issue-message: |
|
||||
此 issue 已超过 60 天没有活动,已被标记为 `stale`。
|
||||
如果此问题仍然存在,请回复以保持 issue 打开状态,否则将在 14 天后自动关闭。
|
||||
|
||||
This issue has been automatically marked as `stale` because it has not had activity for 60 days.
|
||||
Please reply to keep it open, otherwise it will be automatically closed in 14 days.
|
||||
close-issue-message: |
|
||||
此 issue 因长时间无活动已被自动关闭。如果问题仍然存在,欢迎重新打开。
|
||||
|
||||
This issue has been automatically closed due to inactivity. If the problem persists, feel free to reopen it.
|
||||
|
||||
# --- Exemptions ---
|
||||
# Issues with these labels will NEVER be marked stale
|
||||
exempt-issue-labels: 'security,performance'
|
||||
# Issues with 3+ reactions are likely popular requests, exempt them
|
||||
exempt-issue-created-after: '2020-01-01'
|
||||
|
||||
# --- Processing limits ---
|
||||
# Process up to 100 issues per run to avoid API rate limits
|
||||
operations-per-run: 100
|
||||
|
||||
# --- Only issues, not PRs ---
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
@@ -0,0 +1,175 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
> [中文版本](#贡献者公约行为准则)
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **farion1231@gmail.com**. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
|
||||
---
|
||||
|
||||
# 贡献者公约行为准则
|
||||
|
||||
> [English Version](#contributor-covenant-code-of-conduct)
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
身为社区成员、贡献者和领袖,我们承诺使社区参与者不受骚扰,无论其年龄、体型、可见或不可见的缺陷、族裔、性征、性别认同和表达、经验水平、教育程度、社会与经济地位、国籍、相貌、种族、种姓、肤色、宗教信仰、性倾向或性取向如何。
|
||||
|
||||
我们承诺以有助于建立开放、友善、多样化、包容、健康社区的方式行事和互动。
|
||||
|
||||
## 我们的准则
|
||||
|
||||
有助于为我们的社区创造积极环境的行为例子包括但不限于:
|
||||
|
||||
- 表现出对他人的同情和善意
|
||||
- 尊重不同的主张、观点和感受
|
||||
- 提出和大方接受建设性意见
|
||||
- 承担责任并向受我们错误影响的人道歉
|
||||
- 注重社区共同诉求,而非个人得失
|
||||
|
||||
不当行为例子包括:
|
||||
|
||||
- 使用情色化的语言或图像,及性引诱或挑逗
|
||||
- 嘲弄、侮辱或诋毁性评论,以及人身或政治攻击
|
||||
- 公开或私下的骚扰行为
|
||||
- 未经他人明确许可,公布他人的私人信息,如物理或电子邮件地址
|
||||
- 其他有理由认定为违反职业操守的不当行为
|
||||
|
||||
## 责任和权力
|
||||
|
||||
社区领袖有责任解释和落实我们所认可的行为准则,并妥善公正地对他们认为不当、威胁、冒犯或有害的任何行为采取纠正措施。
|
||||
|
||||
社区领袖有权力和责任删除、编辑或拒绝与本行为准则不相符的评论(comment)、提交(able)、代码、维基(wiki)编辑、议题(able)或其他贡献,并在适当时告知采取措施的理由。
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于所有社区场合,也适用于在公共场所代表社区时的个人。
|
||||
|
||||
代表社区的情形包括使用官方电子邮件地址、通过官方社交媒体帐户发帖或在线上或线下活动中担任指定代表。
|
||||
|
||||
## 监督
|
||||
|
||||
辱骂、骚扰或其他不可接受的行为可通过 **farion1231@gmail.com** 向负责监督的社区领袖报告。所有投诉都将得到及时和公平的审查和调查。
|
||||
|
||||
所有社区领袖都有义务尊重任何事件报告者的隐私和安全。
|
||||
|
||||
## 处理方针
|
||||
|
||||
社区领袖将遵循下列社区处理方针来明确他们所认定违反本行为准则的行为的处理方式:
|
||||
|
||||
### 1. 纠正
|
||||
|
||||
**社区影响**:使用不恰当的语言或其他在社区中被认定为不符合职业道德或不受欢迎的行为。
|
||||
|
||||
**处理意见**:由社区领袖发出非公开的书面警告,明确说明违规行为的性质,并解释举止如何不妥。或将要求公开道歉。
|
||||
|
||||
### 2. 警告
|
||||
|
||||
**社区影响**:单个或一系列违规行为。
|
||||
|
||||
**处理意见**:警告并对连续性行为进行处理。在指定时间内,不得与相关人员互动,包括主动与行为准则执行者互动。这包括避免在社区场所和外部渠道中的互动。违反这些条款可能会导致临时或永久封禁。
|
||||
|
||||
### 3. 临时封禁
|
||||
|
||||
**社区影响**:严重违反社区准则,包括持续的不当行为。
|
||||
|
||||
**处理意见**:在指定时间内,暂时禁止与社区进行任何形式的互动或公开交流。在此期间,不得与相关人员进行公开或私下互动,包括主动与行为准则执行者互动。违反这些条款可能会导致永久封禁。
|
||||
|
||||
### 4. 永久封禁
|
||||
|
||||
**社区影响**:行为模式表现出违反社区准则,包括持续的不当行为、骚扰个人或攻击或贬低某个类别的个体。
|
||||
|
||||
**处理意见**:永久禁止在社区内进行任何形式的公开互动。
|
||||
|
||||
## 参见
|
||||
|
||||
本行为准则改编自 [Contributor Covenant][homepage] 2.1 版,参见 [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]。
|
||||
|
||||
社区处理方针灵感来源于 [Mozilla 的行为准则执行阶梯][Mozilla CoC]。
|
||||
|
||||
有关本行为准则的常见问题的答案,参见 [https://www.contributor-covenant.org/faq][FAQ]。其他语言翻译参见 [https://www.contributor-covenant.org/translations][translations]。
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
# Contributing to CC Switch
|
||||
|
||||
> [中文版本](#贡献指南)
|
||||
|
||||
Thank you for your interest in contributing to CC Switch! Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) before participating.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
There are many ways to contribute:
|
||||
|
||||
- **Report bugs** — Found something broken? [Open a bug report](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml).
|
||||
- **Suggest features** — Have an idea? [Submit a feature request](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml).
|
||||
- **Improve docs** — Spot a typo or missing info? [Report a doc issue](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml).
|
||||
- **Contribute code** — Fix bugs or implement features via pull requests.
|
||||
- **Translate** — Help us improve translations for English, Chinese, and Japanese.
|
||||
|
||||
> **Security vulnerabilities**: Please do NOT use public issues. See our [Security Policy](./SECURITY.md) instead.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and pnpm 8+
|
||||
- Rust 1.85+ and Cargo
|
||||
- [Tauri 2.0 prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development server with hot reload
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Start dev server (hot reload) |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm test:unit` | Run unit tests |
|
||||
| `pnpm lint` | ESLint check |
|
||||
| `pnpm format` | Format code (Prettier) |
|
||||
| `pnpm format:check` | Check code formatting |
|
||||
|
||||
For Rust backend:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo fmt # Format Rust code
|
||||
cargo clippy # Run linter
|
||||
cargo test # Run tests
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Frontend**: Prettier for formatting, ESLint for linting, strict TypeScript (`pnpm typecheck`)
|
||||
- **Backend**: `cargo fmt` for formatting, `cargo clippy` for linting
|
||||
- **Tauri 2.0**: Command names must use camelCase
|
||||
|
||||
Run all checks before submitting:
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm format:check && pnpm test:unit
|
||||
cd src-tauri && cargo fmt --check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
1. **Open an issue first** for new features — PRs for features that are not a good fit may be closed.
|
||||
2. **Fork and branch** — Create a feature branch from `main` (e.g., `feat/my-feature` or `fix/issue-123`).
|
||||
3. **Keep PRs focused** — One feature or fix per PR. Avoid unrelated changes.
|
||||
4. **Follow the PR template** — Fill in the summary, related issue, and checklist.
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm format:check` passes
|
||||
- [ ] `cargo clippy` passes (if Rust code changed)
|
||||
- [ ] Updated i18n files if user-facing text changed
|
||||
|
||||
### Commit Convention
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(provider): add support for new provider
|
||||
fix(tray): resolve menu not updating after switch
|
||||
docs(readme): update installation instructions
|
||||
ci: add format check workflow
|
||||
chore(deps): update dependencies
|
||||
```
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
We welcome AI-assisted contributions, but **the responsibility stays with you**. AI tools lower the cost of writing code — they do not lower the cost of reviewing it. Maintainers are not obligated to clean up AI-generated output.
|
||||
|
||||
By submitting a PR, you agree to the following:
|
||||
|
||||
1. **You have read and understood your code.** You must be able to explain any line in your PR. If you cannot, it is not ready for review.
|
||||
2. **You have tested it yourself.** Every change must be verified locally — not just "it looks right." Do not submit code for platforms or features you cannot test.
|
||||
3. **PRs must be small and focused.** One issue, one PR. Large, sprawling, multi-topic PRs will be closed.
|
||||
4. **Open an issue first.** Drive-by PRs with no prior discussion — especially AI-generated ones — may be closed without review.
|
||||
5. **Maintainers may close without explanation.** PRs that appear to be unreviewed AI output — hallucinated fixes, unnecessary refactors, bulk changes with no context — may be closed at the maintainer's discretion.
|
||||
|
||||
**In short**: AI is a tool, not a substitute for understanding. Use it to help you contribute better, not to shift work onto maintainers.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
CC Switch supports three languages. When modifying user-facing text:
|
||||
|
||||
1. Update **all three** locale files:
|
||||
- `src/locales/en/translation.json`
|
||||
- `src/locales/zh/translation.json`
|
||||
- `src/locales/ja/translation.json`
|
||||
2. Use the `t()` function from i18next for all UI text.
|
||||
3. Never hardcode user-facing strings.
|
||||
|
||||
## Questions?
|
||||
|
||||
- [Open a question](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||
|
||||
---
|
||||
|
||||
# 贡献指南
|
||||
|
||||
> [English Version](#contributing-to-cc-switch)
|
||||
|
||||
感谢你对 CC Switch 的贡献兴趣!参与之前请阅读我们的[行为准则](./CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 如何贡献
|
||||
|
||||
你可以通过多种方式参与贡献:
|
||||
|
||||
- **报告 Bug** — 发现问题?[提交 Bug 报告](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml)。
|
||||
- **建议功能** — 有想法?[提交功能请求](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml)。
|
||||
- **改进文档** — 发现错误或缺失?[报告文档问题](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml)。
|
||||
- **贡献代码** — 通过 Pull Request 修复 Bug 或实现新功能。
|
||||
- **翻译** — 帮助改进英文、中文和日文的翻译。
|
||||
|
||||
> **安全漏洞**:请不要使用公开 Issue 报告。请参阅我们的[安全策略](./SECURITY.md)。
|
||||
|
||||
## 开发环境搭建
|
||||
|
||||
### 前提条件
|
||||
|
||||
- Node.js 18+ 和 pnpm 8+
|
||||
- Rust 1.85+ 和 Cargo
|
||||
- [Tauri 2.0 开发环境](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
### 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器(热重载)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `pnpm dev` | 启动开发服务器(热重载) |
|
||||
| `pnpm build` | 构建生产版本 |
|
||||
| `pnpm typecheck` | TypeScript 类型检查 |
|
||||
| `pnpm test:unit` | 运行单元测试 |
|
||||
| `pnpm lint` | ESLint 检查 |
|
||||
| `pnpm format` | 格式化代码(Prettier) |
|
||||
| `pnpm format:check` | 检查代码格式 |
|
||||
|
||||
Rust 后端命令:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo fmt # 格式化 Rust 代码
|
||||
cargo clippy # 运行 Clippy 检查
|
||||
cargo test # 运行测试
|
||||
```
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **前端**:使用 Prettier 格式化、ESLint 检查、严格 TypeScript(`pnpm typecheck`)
|
||||
- **后端**:使用 `cargo fmt` 格式化、`cargo clippy` 检查
|
||||
- **Tauri 2.0**:命令名必须使用 camelCase
|
||||
|
||||
提交前运行所有检查:
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm format:check && pnpm test:unit
|
||||
cd src-tauri && cargo fmt --check && cargo clippy && cargo test
|
||||
```
|
||||
|
||||
## Pull Request 指南
|
||||
|
||||
1. **先开 Issue 讨论** — 新功能请先开 Issue,不适合项目方向的 PR 可能会被关闭。
|
||||
2. **Fork 并创建分支** — 从 `main` 创建功能分支(如 `feat/my-feature` 或 `fix/issue-123`)。
|
||||
3. **保持 PR 专注** — 每个 PR 只做一件事,避免无关改动。
|
||||
4. **遵循 PR 模板** — 填写概述、关联 Issue 和检查清单。
|
||||
|
||||
### PR 检查清单
|
||||
|
||||
- [ ] `pnpm typecheck` 通过
|
||||
- [ ] `pnpm format:check` 通过
|
||||
- [ ] `cargo clippy` 通过(如修改了 Rust 代码)
|
||||
- [ ] 如修改了用户可见文本,已更新国际化文件
|
||||
|
||||
### 提交信息规范
|
||||
|
||||
我们使用 [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(provider): add support for new provider
|
||||
fix(tray): resolve menu not updating after switch
|
||||
docs(readme): update installation instructions
|
||||
ci: add format check workflow
|
||||
chore(deps): update dependencies
|
||||
```
|
||||
|
||||
## AI 辅助贡献
|
||||
|
||||
我们欢迎 AI 辅助的贡献,但**责任始终在你身上**。AI 工具降低了写代码的成本,但并没有降低 review 的成本。维护者没有义务替你清理 AI 的产出。
|
||||
|
||||
提交 PR 即表示你同意以下规则:
|
||||
|
||||
1. **你已阅读并理解了你的代码。** 你必须能解释 PR 中的每一行。如果做不到,说明还没准备好提交 review。
|
||||
2. **你已亲自测试过。** 每个改动都必须在本地验证——而不是"看起来对"。不要提交你自己无法测试的平台或功能的代码。
|
||||
3. **PR 必须小而聚焦。** 一个 Issue 对应一个 PR。大而散、跨多个主题的 PR 会被直接关闭。
|
||||
4. **先开 Issue 讨论。** 没有事先讨论的"路过式 PR"——尤其是 AI 生成的——可能会被直接关闭。
|
||||
5. **维护者可以直接关闭。** 看起来是未经审阅的 AI 产出的 PR——虚构的修复、不必要的重构、缺乏上下文的批量改动——维护者可自行决定关闭。
|
||||
|
||||
**一句话总结**:AI 是工具,不是理解力的替代品。用它来帮助你更好地贡献,而不是把工作转移给维护者。
|
||||
|
||||
## 国际化(i18n)
|
||||
|
||||
CC Switch 支持三种语言。修改用户可见文本时:
|
||||
|
||||
1. **同时更新三个**语言文件:
|
||||
- `src/locales/en/translation.json`
|
||||
- `src/locales/zh/translation.json`
|
||||
- `src/locales/ja/translation.json`
|
||||
2. 所有 UI 文本使用 i18next 的 `t()` 函数。
|
||||
3. 不要硬编码用户可见的字符串。
|
||||
|
||||
## 有疑问?
|
||||
|
||||
- [提问](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- [GitHub 讨论区](https://github.com/farion1231/cc-switch/discussions)
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -17,6 +17,8 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
> [Want to appear here?](mailto:farion1231@gmail.com)
|
||||
|
||||
<details open>
|
||||
<summary>Click to collapse</summary>
|
||||
|
||||
@@ -100,6 +102,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -287,14 +294,8 @@ Download the latest Linux build from the [Releases](../../releases) page:
|
||||
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
|
||||
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
|
||||
|
||||
Flatpak install & run:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
> **Flatpak**: Not included in official releases. You can build it yourself from the `.deb` — see [`flatpak/README.md`](flatpak/README.md) for instructions.
|
||||
|
||||
<details>
|
||||
<summary><strong>Architecture Overview</strong></summary>
|
||||
@@ -333,7 +334,7 @@ flatpak run com.ccswitch.desktop
|
||||
- **ProviderService**: Provider CRUD, switching, backfill, sorting
|
||||
- **McpService**: MCP server management, import/export, live file sync
|
||||
- **ProxyService**: Local proxy mode with hot-switching and format conversion
|
||||
- **SessionManager**: Claude Code conversation history browsing
|
||||
- **SessionManager**: Conversation history browsing across all supported apps
|
||||
- **ConfigService**: Config import/export, backup rotation
|
||||
- **SpeedtestService**: API endpoint latency measurement
|
||||
|
||||
|
||||
+13
-12
@@ -4,10 +4,10 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
## ❤️スポンサー
|
||||
|
||||
> [ここに掲載しませんか?](mailto:farion1231@gmail.com)
|
||||
|
||||
<details open>
|
||||
<summary>クリックで折りたたむ</summary>
|
||||
|
||||
@@ -100,6 +102,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -184,9 +191,9 @@ CC Switch には「共有設定スニペット」機能があり、APIキーや
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS で「開発元を確認できません」と表示されます。どうすればよいですか?</strong></summary>
|
||||
<summary><strong>macOS のインストールについて</strong></summary>
|
||||
|
||||
開発者が Apple Developer アカウントをまだ取得していないためです(登録手続き中)。警告を閉じてから、**システム設定 → プライバシーとセキュリティ → このまま開く**をクリックしてください。以降は通常通り起動できます。
|
||||
CC Switch の macOS 版は Apple によるコード署名と公証が完了しています。直接ダウンロードしてインストールできます — 追加の手順は不要です。`.dmg` インストーラの使用を推奨します。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -287,14 +294,8 @@ paru -S cc-switch-bin
|
||||
- `CC-Switch-v{version}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{version}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
|
||||
- `CC-Switch-v{version}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak のインストールと起動:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
> **Flatpak**:公式リリースには含まれていません。`.deb` から自分でビルドできます — 手順は [`flatpak/README.md`](flatpak/README.md) を参照してください。
|
||||
|
||||
<details>
|
||||
<summary><strong>アーキテクチャ概要</strong></summary>
|
||||
@@ -333,7 +334,7 @@ flatpak run com.ccswitch.desktop
|
||||
- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート
|
||||
- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期
|
||||
- **ProxyService**: ローカル Proxy モードのホットスイッチとフォーマット変換
|
||||
- **SessionManager**: Claude Code の会話履歴閲覧
|
||||
- **SessionManager**: 対応する全アプリの会話履歴閲覧
|
||||
- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション
|
||||
- **SpeedtestService**: API エンドポイントの遅延計測
|
||||
|
||||
|
||||
+11
-10
@@ -4,10 +4,10 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
> [想出现在这里?](mailto:farion1231@gmail.com)
|
||||
|
||||
<details open>
|
||||
<summary>点击折叠</summary>
|
||||
|
||||
@@ -101,6 +103,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -290,14 +297,8 @@ paru -S cc-switch-bin
|
||||
- `CC-Switch-v{版本号}-Linux.deb`(Debian/Ubuntu)
|
||||
- `CC-Switch-v{版本号}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
|
||||
- `CC-Switch-v{版本号}-Linux.flatpak`(Flatpak)
|
||||
|
||||
Flatpak 安装与运行:
|
||||
|
||||
```bash
|
||||
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
|
||||
flatpak run com.ccswitch.desktop
|
||||
```
|
||||
> **Flatpak**:官方 Release 不包含 Flatpak 包。如需使用,可从 `.deb` 自行构建 — 参见 [`flatpak/README.md`](flatpak/README.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>架构总览</strong></summary>
|
||||
@@ -336,7 +337,7 @@ flatpak run com.ccswitch.desktop
|
||||
- **ProviderService**:供应商增删改查、切换、回填、排序
|
||||
- **McpService**:MCP 服务器管理、导入导出、live 文件同步
|
||||
- **ProxyService**:本地 Proxy 模式,支持热切换和格式转换
|
||||
- **SessionManager**:Claude Code 对话历史浏览
|
||||
- **SessionManager**:全应用会话历史浏览
|
||||
- **ConfigService**:配置导入导出、备份轮换
|
||||
- **SpeedtestService**:API 端点延迟测量
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Security Policy / 安全策略
|
||||
|
||||
## Supported Versions / 支持的版本
|
||||
|
||||
Only the latest release of CC Switch receives security updates.
|
||||
|
||||
仅最新版本的 CC Switch 会收到安全更新。
|
||||
|
||||
| Version / 版本 | Supported / 是否支持 |
|
||||
|----------------|---------------------|
|
||||
| Latest 3.x | ✅ Yes / 是 |
|
||||
| < 3.0 | ❌ No / 否 |
|
||||
|
||||
## Reporting a Vulnerability / 报告漏洞
|
||||
|
||||
**Please do NOT report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
**请不要通过公开的 GitHub Issue 报告安全漏洞。**
|
||||
|
||||
Instead, please report them through [GitHub Security Advisories](https://github.com/farion1231/cc-switch/security/advisories/new).
|
||||
|
||||
请通过 [GitHub 安全公告](https://github.com/farion1231/cc-switch/security/advisories/new) 进行报告。
|
||||
|
||||
When reporting, please include:
|
||||
|
||||
报告时请包含以下信息:
|
||||
|
||||
- A description of the vulnerability / 漏洞描述
|
||||
- Steps to reproduce / 复现步骤
|
||||
- Potential impact / 潜在影响
|
||||
- Affected versions / 受影响版本
|
||||
|
||||
## Response Timeline / 响应时间
|
||||
|
||||
- **Acknowledgment / 确认**: within 48 hours / 48 小时内
|
||||
- **Initial assessment / 初步评估**: within 7 days / 7 天内
|
||||
- **Fix for critical issues / 关键问题修复**: within 14 days / 14 天内
|
||||
|
||||
## Disclosure Policy / 披露政策
|
||||
|
||||
We follow a coordinated disclosure process:
|
||||
|
||||
我们遵循协调披露流程:
|
||||
|
||||
1. The reporter submits the vulnerability privately. / 报告者私下提交漏洞。
|
||||
2. We confirm and work on a fix. / 我们确认并修复漏洞。
|
||||
3. A patch release is published. / 发布修复版本。
|
||||
4. The vulnerability is publicly disclosed. / 公开披露漏洞详情。
|
||||
|
||||
Reporters will be credited in the release notes unless they prefer to remain anonymous.
|
||||
|
||||
除非报告者希望匿名,否则将在发布说明中致谢。
|
||||
|
||||
## Security Updates / 安全更新
|
||||
|
||||
Security fixes are released as patch versions and announced via [GitHub Releases](https://github.com/farion1231/cc-switch/releases). We recommend always updating to the latest version.
|
||||
|
||||
安全修复通过补丁版本发布,并通过 [GitHub Releases](https://github.com/farion1231/cc-switch/releases) 通知。建议始终更新到最新版本。
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Support / 获取帮助
|
||||
|
||||
> [中文版本](#获取帮助)
|
||||
|
||||
## How to Get Help
|
||||
|
||||
CC Switch is an open-source project maintained by volunteers. We're happy to help, but please use the right channel so we can respond efficiently.
|
||||
|
||||
### Before Asking
|
||||
|
||||
1. **Read the [FAQ](https://github.com/farion1231/cc-switch#faq)** — most common questions are answered there.
|
||||
2. **Search [existing issues](https://github.com/farion1231/cc-switch/issues)** (including closed ones) — someone may have had the same question.
|
||||
|
||||
### Asking a Question
|
||||
|
||||
- **Usage or configuration questions**: [Open a Question issue](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- **General discussion**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||
|
||||
### Reporting Problems
|
||||
|
||||
- **Bug reports**: [Open a Bug Report](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml)
|
||||
- **Documentation issues**: [Open a Doc Issue](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml)
|
||||
- **Security vulnerabilities**: Please do NOT use public issues. See our [Security Policy](./SECURITY.md).
|
||||
|
||||
### Feature Requests
|
||||
|
||||
- [Submit a Feature Request](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml)
|
||||
- Please open an issue for discussion before submitting a PR for new features.
|
||||
|
||||
---
|
||||
|
||||
# 获取帮助
|
||||
|
||||
> [English Version](#support--获取帮助)
|
||||
|
||||
## 如何获取帮助
|
||||
|
||||
CC Switch 是一个由志愿者维护的开源项目。我们很乐意提供帮助,但请使用合适的渠道,以便我们高效响应。
|
||||
|
||||
### 提问之前
|
||||
|
||||
1. **阅读 [常见问题](https://github.com/farion1231/cc-switch#常见问题)** — 大多数常见问题都已在其中解答。
|
||||
2. **搜索 [已有的 Issue](https://github.com/farion1231/cc-switch/issues)**(包括已关闭的) — 可能已经有人问过相同的问题。
|
||||
|
||||
### 提问
|
||||
|
||||
- **使用或配置问题**:[提交问题 Issue](https://github.com/farion1231/cc-switch/issues/new?template=question.yml)
|
||||
- **一般讨论**:[GitHub 讨论区](https://github.com/farion1231/cc-switch/discussions)
|
||||
|
||||
### 报告问题
|
||||
|
||||
- **Bug 报告**:[提交 Bug 报告](https://github.com/farion1231/cc-switch/issues/new?template=bug_report.yml)
|
||||
- **文档问题**:[提交文档问题](https://github.com/farion1231/cc-switch/issues/new?template=doc_issue.yml)
|
||||
- **安全漏洞**:请不要使用公开 Issue。请参阅我们的[安全策略](./SECURITY.md)。
|
||||
|
||||
### 功能请求
|
||||
|
||||
- [提交功能请求](https://github.com/farion1231/cc-switch/issues/new?template=feature_request.yml)
|
||||
- 提交新功能的 PR 之前,请先开 Issue 讨论。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
@@ -12,8 +12,8 @@
|
||||
|
||||
## Version / 版本 / バージョン
|
||||
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Documentation version: v3.12.3
|
||||
- Last updated: 2026-04-02
|
||||
- Compatible with CC Switch v3.12.0+
|
||||
|
||||
## Links
|
||||
|
||||
@@ -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: `{}`).
|
||||
|
||||
+174
-14
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
@@ -720,8 +720,10 @@ pub async fn open_provider_terminal(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
cwd: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
let launch_cwd = resolve_launch_cwd(cwd)?;
|
||||
|
||||
// 获取提供商配置
|
||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||
@@ -736,7 +738,8 @@ pub async fn open_provider_terminal(
|
||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||
|
||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
|
||||
.map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -791,11 +794,49 @@ fn extract_env_vars_from_config(
|
||||
env_vars
|
||||
}
|
||||
|
||||
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
|
||||
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if raw_path.contains('\n') || raw_path.contains('\r') {
|
||||
return Err("目录路径包含非法换行符".to_string());
|
||||
}
|
||||
|
||||
let path = Path::new(&raw_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("目录不存在: {raw_path}"));
|
||||
}
|
||||
|
||||
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
|
||||
}
|
||||
|
||||
// Strip Windows extended-length prefix that canonicalize produces,
|
||||
// as it can break batch scripts and other shell commands.
|
||||
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
|
||||
#[cfg(target_os = "windows")]
|
||||
let resolved = {
|
||||
let s = resolved.to_string_lossy();
|
||||
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
|
||||
PathBuf::from(format!(r"\\{unc}"))
|
||||
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
|
||||
PathBuf::from(stripped)
|
||||
} else {
|
||||
resolved
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(resolved))
|
||||
}
|
||||
|
||||
/// 创建临时配置文件并启动 claude 终端
|
||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||
fn launch_terminal_with_env(
|
||||
env_vars: Vec<(String, String)>,
|
||||
provider_id: &str,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let config_file = temp_dir.join(format!(
|
||||
@@ -809,19 +850,19 @@ fn launch_terminal_with_env(
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file)?;
|
||||
launch_macos_terminal(&config_file, cwd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file)?;
|
||||
launch_linux_terminal(&config_file, cwd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -851,7 +892,7 @@ fn write_claude_config(
|
||||
|
||||
/// macOS: 根据用户首选终端启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
@@ -860,18 +901,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
let cd_command = build_shell_cd_command(cwd);
|
||||
|
||||
// Write the shell script to a temp file
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
{cd_command}
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display()
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -1007,7 +1051,7 @@ fn launch_macos_open_app(
|
||||
|
||||
/// Linux: 根据用户首选终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1029,17 +1073,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
let cd_command = build_shell_cd_command(cwd);
|
||||
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
{cd_command}
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display()
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -1053,21 +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()
|
||||
};
|
||||
|
||||
@@ -1118,22 +1165,28 @@ fn which_command(cmd: &str) -> bool {
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), String> {
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
|
||||
let cwd_command = build_windows_cwd_command(cwd);
|
||||
|
||||
let content = format!(
|
||||
"@echo off
|
||||
{cwd_command}
|
||||
echo Using provider-specific claude config:
|
||||
echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
",
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
config_path_for_batch,
|
||||
config_path_for_batch,
|
||||
config_path_for_batch,
|
||||
cwd_command = cwd_command,
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
@@ -1164,6 +1217,55 @@ del \"%~f0\" >nul 2>&1
|
||||
result
|
||||
}
|
||||
|
||||
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
|
||||
cwd.map(|dir| {
|
||||
format!(
|
||||
"cd {} || exit 1\n",
|
||||
shell_single_quote(&dir.to_string_lossy())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn is_windows_unc_path(path: &str) -> bool {
|
||||
path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn build_windows_cwd_command_str(path: &str) -> String {
|
||||
let escaped = escape_windows_batch_value(path);
|
||||
|
||||
if is_windows_unc_path(path) {
|
||||
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
|
||||
format!("pushd \"{escaped}\" || exit /b 1\r\n")
|
||||
} else {
|
||||
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
|
||||
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn escape_windows_batch_value(value: &str) -> String {
|
||||
value
|
||||
.replace('^', "^^")
|
||||
.replace('%', "%%")
|
||||
.replace('&', "^&")
|
||||
.replace('|', "^|")
|
||||
.replace('<', "^<")
|
||||
.replace('>', "^>")
|
||||
.replace('(', "^(")
|
||||
.replace(')', "^)")
|
||||
}
|
||||
/// Windows: Run a start command with common error handling
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||||
@@ -1338,4 +1440,62 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_cwd_accepts_existing_directory() {
|
||||
let resolved =
|
||||
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
|
||||
.expect("temp dir should resolve")
|
||||
.expect("temp dir should be present");
|
||||
|
||||
assert!(resolved.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_launch_cwd_rejects_missing_directory() {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_nanos();
|
||||
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
|
||||
|
||||
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
|
||||
.expect_err("missing directory should fail");
|
||||
|
||||
assert!(error.contains("目录不存在"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
|
||||
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
|
||||
|
||||
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
|
||||
let command = build_windows_cwd_command_str(r"C:\work\repo");
|
||||
|
||||
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
|
||||
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
|
||||
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
mod lightweight;
|
||||
|
||||
pub use auth::*;
|
||||
pub use config::*;
|
||||
@@ -48,7 +48,7 @@ pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
pub use lightweight::*;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -74,3 +74,12 @@ pub async fn delete_session(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_sessions(
|
||||
items: Vec<session_manager::DeleteSessionRequest>,
|
||||
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete sessions: {e}"))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1021,6 +1021,7 @@ pub fn run() {
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::delete_session,
|
||||
commands::delete_sessions,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
|
||||
@@ -87,4 +87,4 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
pub fn is_lightweight_mode() -> bool {
|
||||
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use crate::error::AppError;
|
||||
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
|
||||
use chrono::Local;
|
||||
use indexmap::IndexMap;
|
||||
use json_five::parser::{FormatConfiguration, TrailingComma};
|
||||
use json_five::rt::parser::{
|
||||
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
|
||||
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
|
||||
@@ -38,9 +37,7 @@ pub fn get_openclaw_dir() -> PathBuf {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".openclaw"))
|
||||
.unwrap_or_else(|| PathBuf::from(".openclaw"))
|
||||
crate::config::get_home_dir().join(".openclaw")
|
||||
}
|
||||
|
||||
/// 获取 OpenClaw 配置文件路径
|
||||
@@ -490,11 +487,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
|
||||
}
|
||||
|
||||
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
|
||||
let source = json_five::to_string_formatted(
|
||||
value,
|
||||
FormatConfiguration::with_indent(2, TrailingComma::NONE),
|
||||
)
|
||||
.map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?;
|
||||
// `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays.
|
||||
// Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and
|
||||
// can still be parsed back into the round-trip AST we use for insertion.
|
||||
let source = serde_json::to_string_pretty(value)
|
||||
.map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?;
|
||||
|
||||
let adjusted = reindent_json5_block(&source, parent_indent);
|
||||
let text = rt_from_str(&adjusted).map_err(|e| {
|
||||
@@ -1051,4 +1048,37 @@ mod tests {
|
||||
assert!(err.to_string().contains("OpenClaw config changed on disk"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_provider_writes_empty_providers_without_panic() {
|
||||
let source = r#"{
|
||||
models: {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
'1-copy': {
|
||||
api: 'anthropic-messages',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
with_test_paths(source, |_| {
|
||||
let outcome = remove_provider("1-copy").unwrap();
|
||||
assert!(outcome.backup_path.is_some());
|
||||
|
||||
let config = read_openclaw_config().unwrap();
|
||||
let providers = config
|
||||
.get("models")
|
||||
.and_then(|models| models.get("providers"))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(providers.is_empty());
|
||||
|
||||
let written = fs::read_to_string(get_openclaw_config_path()).unwrap();
|
||||
assert!(written.contains("\"providers\": {}"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,40 @@ use indexmap::IndexMap;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
|
||||
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
|
||||
|
||||
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
|
||||
plugin_name == prefix
|
||||
|| plugin_name
|
||||
.strip_prefix(prefix)
|
||||
.map(|suffix| suffix.starts_with('@'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
|
||||
prefixes
|
||||
.iter()
|
||||
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
|
||||
}
|
||||
|
||||
fn canonicalize_plugin_name(plugin_name: &str) -> String {
|
||||
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
|
||||
if suffix.is_empty() || suffix.starts_with('@') {
|
||||
return format!("oh-my-openagent{suffix}");
|
||||
}
|
||||
}
|
||||
plugin_name.to_string()
|
||||
}
|
||||
|
||||
pub fn get_opencode_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_opencode_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config").join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
crate::config::get_home_dir()
|
||||
.join(".config")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
pub fn get_opencode_config_path() -> PathBuf {
|
||||
@@ -140,58 +166,56 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
|
||||
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
|
||||
|
||||
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
||||
|
||||
match plugins {
|
||||
Some(arr) => {
|
||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||
if plugin_name.starts_with("oh-my-opencode")
|
||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||
{
|
||||
// Adding standard OMO -> remove all Slim variants
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
{
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
|
||||
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||
let already_exists = arr
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
|
||||
if !already_exists {
|
||||
arr.push(Value::String(plugin_name.to_string()));
|
||||
arr.push(Value::String(normalized_plugin_name));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
config["plugin"] = json!([plugin_name]);
|
||||
config["plugin"] = json!([normalized_plugin_name]);
|
||||
}
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
|
||||
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
if !s.starts_with(prefix) {
|
||||
return true; // Keep: doesn't match prefix at all
|
||||
}
|
||||
let rest = &s[prefix.len()..];
|
||||
rest.starts_with('-')
|
||||
})
|
||||
.map(|s| !matches_any_plugin_prefix(s, prefixes))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
//!
|
||||
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
||||
//! - 去重控制(避免多个请求同时触发)
|
||||
//! - 数据库更新
|
||||
//! - 托盘菜单更新
|
||||
//! - 前端事件发射
|
||||
//! - Live 备份更新
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -98,30 +95,21 @@ impl FailoverSwitchManager {
|
||||
|
||||
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||
|
||||
// 1. 更新数据库 is_current
|
||||
self.db.set_current_provider(app_type, provider_id)?;
|
||||
let mut switched = false;
|
||||
|
||||
// 2. 更新本地 settings(设备级)
|
||||
let app_type_enum = crate::app_config::AppType::from_str(app_type)
|
||||
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
|
||||
|
||||
// 3. 更新托盘菜单和发射事件
|
||||
if let Some(app) = app_handle {
|
||||
// 更新托盘菜单
|
||||
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
|
||||
// 更新 Live 备份(确保代理停止时恢复正确配置)
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||
if let Err(e) = app_state
|
||||
.proxy_service
|
||||
.update_live_backup_from_provider(app_type, &provider)
|
||||
.await
|
||||
{
|
||||
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||
}
|
||||
switched = app_state
|
||||
.proxy_service
|
||||
.hot_switch_provider(app_type, provider_id)
|
||||
.await
|
||||
.map_err(AppError::Message)?
|
||||
.logical_target_changed;
|
||||
|
||||
if !switched {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 重建托盘菜单
|
||||
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
@@ -142,6 +130,6 @@ impl FailoverSwitchManager {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
Ok(switched)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub(crate) mod sse;
|
||||
pub(crate) mod switch_lock;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
|
||||
@@ -272,6 +272,7 @@ mod tests {
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
original_test_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
@@ -279,15 +280,18 @@ mod tests {
|
||||
let dir = TempDir::new().expect("failed to create temp home");
|
||||
let original_home = env::var("HOME").ok();
|
||||
let original_userprofile = env::var("USERPROFILE").ok();
|
||||
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
|
||||
|
||||
env::set_var("HOME", dir.path());
|
||||
env::set_var("USERPROFILE", dir.path());
|
||||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
Self {
|
||||
dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
original_test_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,6 +307,11 @@ mod tests {
|
||||
Some(value) => env::set_var("USERPROFILE", value),
|
||||
None => env::remove_var("USERPROFILE"),
|
||||
}
|
||||
|
||||
match &self.original_test_home {
|
||||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -974,7 +974,9 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let events: Vec<Value> = chunks
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Per-app switch lock
|
||||
//!
|
||||
//! 确保同一应用同时只有一个供应商切换操作在执行,
|
||||
//! 防止并发切换导致 is_current 与 Live 备份不一致。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
|
||||
|
||||
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
|
||||
///
|
||||
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SwitchLockManager {
|
||||
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl SwitchLockManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 获取指定应用的切换锁。
|
||||
///
|
||||
/// 返回 `OwnedMutexGuard`,持有期间同一 `app_type` 的其他切换会排队等待。
|
||||
pub async fn lock_for_app(&self, app_type: &str) -> OwnedMutexGuard<()> {
|
||||
let lock = {
|
||||
let locks = self.locks.read().await;
|
||||
if let Some(lock) = locks.get(app_type) {
|
||||
lock.clone()
|
||||
} else {
|
||||
drop(locks);
|
||||
let mut locks = self.locks.write().await;
|
||||
locks
|
||||
.entry(app_type.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
};
|
||||
lock.lock_owned().await
|
||||
}
|
||||
}
|
||||
+150
-44
@@ -1,6 +1,7 @@
|
||||
use crate::config::write_json_file;
|
||||
use crate::config::{atomic_write, write_json_file};
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
@@ -21,33 +22,41 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
|
||||
// ── Variant descriptor ─────────────────────────────────────────
|
||||
|
||||
pub struct OmoVariant {
|
||||
pub filename: &'static str,
|
||||
pub preferred_filename: &'static str,
|
||||
pub config_candidates: &'static [&'static str],
|
||||
pub category: &'static str,
|
||||
pub provider_prefix: &'static str,
|
||||
pub plugin_name: &'static str,
|
||||
pub plugin_prefix: &'static str,
|
||||
pub plugin_prefixes: &'static [&'static str],
|
||||
pub has_categories: bool,
|
||||
pub label: &'static str,
|
||||
pub import_label: &'static str,
|
||||
}
|
||||
|
||||
pub const STANDARD: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode.jsonc",
|
||||
preferred_filename: "oh-my-openagent.jsonc",
|
||||
config_candidates: &[
|
||||
"oh-my-openagent.jsonc",
|
||||
"oh-my-openagent.json",
|
||||
"oh-my-opencode.jsonc",
|
||||
"oh-my-opencode.json",
|
||||
],
|
||||
category: "omo",
|
||||
provider_prefix: "omo-",
|
||||
plugin_name: "oh-my-opencode@latest",
|
||||
plugin_prefix: "oh-my-opencode",
|
||||
plugin_name: "oh-my-openagent@latest",
|
||||
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
|
||||
has_categories: true,
|
||||
label: "OMO",
|
||||
import_label: "Imported",
|
||||
};
|
||||
|
||||
pub const SLIM: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode-slim.jsonc",
|
||||
preferred_filename: "oh-my-opencode-slim.jsonc",
|
||||
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
|
||||
category: "omo-slim",
|
||||
provider_prefix: "omo-slim-",
|
||||
plugin_name: "oh-my-opencode-slim@latest",
|
||||
plugin_prefix: "oh-my-opencode-slim",
|
||||
plugin_prefixes: &["oh-my-opencode-slim"],
|
||||
has_categories: false,
|
||||
label: "OMO Slim",
|
||||
import_label: "Imported Slim",
|
||||
@@ -60,22 +69,27 @@ pub struct OmoService;
|
||||
impl OmoService {
|
||||
// ── Path helpers ────────────────────────────────────────
|
||||
|
||||
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
|
||||
v.config_candidates
|
||||
.iter()
|
||||
.map(|name| base_dir.join(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
|
||||
Self::config_candidates(v, base_dir)
|
||||
.into_iter()
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||
get_opencode_dir().join(v.filename)
|
||||
let base_dir = get_opencode_dir();
|
||||
Self::find_existing_config_path(v, &base_dir)
|
||||
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
|
||||
}
|
||||
|
||||
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
return Ok(config_path);
|
||||
}
|
||||
|
||||
let json_path = config_path.with_extension("json");
|
||||
if json_path.exists() {
|
||||
return Ok(json_path);
|
||||
}
|
||||
|
||||
Err(AppError::OmoConfigNotFound)
|
||||
Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
|
||||
}
|
||||
|
||||
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
|
||||
@@ -120,44 +134,102 @@ impl OmoService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API (variant-parameterized) ─────────────────
|
||||
|
||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
log::info!("{} config file deleted: {config_path:?}", v.label);
|
||||
}
|
||||
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
|
||||
Ok(())
|
||||
fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
|
||||
let agents = provider.settings_config.get("agents").cloned();
|
||||
let categories = if v.has_categories {
|
||||
provider.settings_config.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let other_fields = provider.settings_config.get("otherFields").cloned();
|
||||
(agents, categories, other_fields)
|
||||
}
|
||||
|
||||
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||
let profile_data = current_omo.as_ref().map(|p| {
|
||||
let agents = p.settings_config.get("agents").cloned();
|
||||
let categories = if v.has_categories {
|
||||
p.settings_config.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||
(agents, categories, other_fields)
|
||||
});
|
||||
fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let merged = Self::build_config(v, profile_data.as_ref());
|
||||
std::fs::read(path)
|
||||
.map(Some)
|
||||
.map_err(|e| AppError::io(path, e))
|
||||
}
|
||||
|
||||
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
|
||||
match snapshot {
|
||||
Some(bytes) => atomic_write(path, bytes),
|
||||
None => {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_profile_config(
|
||||
v: &OmoVariant,
|
||||
profile_data: Option<&OmoProfileData>,
|
||||
) -> Result<(), AppError> {
|
||||
let merged = Self::build_config(v, profile_data);
|
||||
let config_path = Self::config_path(v);
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let previous_contents = Self::snapshot_config_file(&config_path)?;
|
||||
write_json_file(&config_path, &merged)?;
|
||||
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||
if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
|
||||
if let Err(rollback_err) =
|
||||
Self::restore_config_file(&config_path, previous_contents.as_deref())
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to roll back {} config after plugin sync error: {}",
|
||||
v.label,
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
log::info!("{} config written to {config_path:?}", v.label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Public API (variant-parameterized) ─────────────────
|
||||
|
||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||
let base_dir = get_opencode_dir();
|
||||
let mut deleted_paths = Vec::new();
|
||||
for config_path in Self::config_candidates(v, &base_dir) {
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
deleted_paths.push(config_path);
|
||||
}
|
||||
}
|
||||
if !deleted_paths.is_empty() {
|
||||
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
|
||||
}
|
||||
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||
let profile_data = current_omo
|
||||
.as_ref()
|
||||
.map(|provider| Self::profile_data_from_provider(provider, v));
|
||||
Self::write_profile_config(v, profile_data.as_ref())
|
||||
}
|
||||
|
||||
pub fn write_provider_config_to_file(
|
||||
provider: &Provider,
|
||||
v: &OmoVariant,
|
||||
) -> Result<(), AppError> {
|
||||
let profile_data = Self::profile_data_from_provider(provider, v);
|
||||
Self::write_profile_config(v, Some(&profile_data))
|
||||
}
|
||||
|
||||
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
||||
let mut result = Map::new();
|
||||
if let Some((agents, categories, other_fields)) = profile_data {
|
||||
@@ -451,4 +523,38 @@ mod tests {
|
||||
assert!(obj.contains_key("agents"));
|
||||
assert!(obj.contains_key("disabled_agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_existing_config_prefers_new_name_over_old() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
||||
let new_path = dir.path().join("oh-my-openagent.jsonc");
|
||||
|
||||
// Create both old and new files
|
||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
||||
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
|
||||
|
||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
||||
assert_eq!(
|
||||
found.unwrap(),
|
||||
new_path,
|
||||
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_existing_config_falls_back_to_old_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_path = dir.path().join("oh-my-opencode.jsonc");
|
||||
|
||||
// Only old file exists
|
||||
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
|
||||
|
||||
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
|
||||
assert_eq!(
|
||||
found.unwrap(),
|
||||
old_path,
|
||||
"When only the old config file exists, it should still be found"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
pub(crate) fn provider_exists_in_live_config(
|
||||
app_type: &AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
match app_type {
|
||||
AppType::OpenCode => crate::opencode_config::get_providers()
|
||||
.map(|providers| providers.contains_key(provider_id)),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_providers()
|
||||
.map(|providers| providers.contains_key(provider_id)),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_is_subset(target: &Value, source: &Value) -> bool {
|
||||
match source {
|
||||
Value::Object(source_map) => {
|
||||
@@ -727,10 +740,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenCode provider '{}' has invalid config structure, skipping write",
|
||||
return Err(AppError::Message(format!(
|
||||
"OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')",
|
||||
provider.id
|
||||
);
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -769,10 +782,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
provider.id
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"OpenClaw provider '{}' has invalid config structure, skipping write",
|
||||
return Err(AppError::Message(format!(
|
||||
"OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')",
|
||||
provider.id
|
||||
);
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -787,23 +800,30 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
/// Used for OpenCode and other additive mode applications.
|
||||
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let mut synced_count = 0usize;
|
||||
|
||||
for provider in providers.values() {
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.live_config_managed)
|
||||
== Some(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
provider.id
|
||||
);
|
||||
// Continue syncing other providers, don't abort
|
||||
continue;
|
||||
}
|
||||
synced_count += 1;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Synced {} {:?} providers to live config",
|
||||
providers.len(),
|
||||
app_type
|
||||
);
|
||||
log::info!("Synced {synced_count} {app_type:?} providers to live config");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1207,12 +1227,16 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
};
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(
|
||||
let mut provider = Provider::with_id(
|
||||
id.clone(),
|
||||
config.name.clone().unwrap_or_else(|| id.clone()),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
live_config_managed: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("opencode", &provider) {
|
||||
@@ -1277,7 +1301,11 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
provider.meta = Some(crate::provider::ProviderMeta {
|
||||
live_config_managed: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
|
||||
+1086
-143
File diff suppressed because it is too large
Load Diff
+458
-144
@@ -7,6 +7,7 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::switch_lock::SwitchLockManager;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
@@ -39,6 +40,12 @@ pub struct ProxyService {
|
||||
server: Arc<RwLock<Option<ProxyServer>>>,
|
||||
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
||||
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
||||
switch_locks: SwitchLockManager,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HotSwitchOutcome {
|
||||
pub logical_target_changed: bool,
|
||||
}
|
||||
|
||||
impl ProxyService {
|
||||
@@ -47,6 +54,7 @@ impl ProxyService {
|
||||
db,
|
||||
server: Arc::new(RwLock::new(None)),
|
||||
app_handle: Arc::new(RwLock::new(None)),
|
||||
switch_locks: SwitchLockManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +83,68 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {
|
||||
if !config.is_object() {
|
||||
*config = json!({});
|
||||
}
|
||||
|
||||
let root = config
|
||||
.as_object_mut()
|
||||
.expect("Claude config should be normalized to an object");
|
||||
let env = root.entry("env".to_string()).or_insert_with(|| json!({}));
|
||||
if !env.is_object() {
|
||||
*env = json!({});
|
||||
}
|
||||
|
||||
let env = env
|
||||
.as_object_mut()
|
||||
.expect("Claude env should be normalized to an object");
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(proxy_url));
|
||||
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sync_claude_live_from_provider_while_proxy_active(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let mut effective_settings = build_effective_settings_with_common_config(
|
||||
self.db.as_ref(),
|
||||
&AppType::Claude,
|
||||
provider,
|
||||
)
|
||||
.map_err(|e| format!("构建 claude 有效配置失败: {e}"))?;
|
||||
let (proxy_url, _) = self.build_proxy_urls().await?;
|
||||
|
||||
Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);
|
||||
self.write_claude_live(&effective_settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置 AppHandle(在应用初始化时调用)
|
||||
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
|
||||
futures::executor::block_on(async {
|
||||
@@ -841,41 +911,7 @@ impl ProxyService {
|
||||
|
||||
// Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
// 仅覆盖已存在的 Token 字段,避免新增字段导致用户困惑;
|
||||
// 若完全没有 Token 字段,则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
live_config["env"] = json!({
|
||||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
}
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
self.write_claude_live(&live_config)?;
|
||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
@@ -925,41 +961,7 @@ impl ProxyService {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let mut live_config = self.read_claude_live()?;
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
live_config["env"] = json!({
|
||||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
}
|
||||
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
self.write_claude_live(&live_config)?;
|
||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
@@ -1016,41 +1018,7 @@ impl ProxyService {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if let Ok(mut live_config) = self.read_claude_live() {
|
||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
|
||||
let mut replaced_any = false;
|
||||
for key in token_keys {
|
||||
if env.contains_key(key) {
|
||||
env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
|
||||
replaced_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced_any {
|
||||
env.insert(
|
||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||
json!(PROXY_TOKEN_PLACEHOLDER),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
live_config["env"] = json!({
|
||||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||||
});
|
||||
}
|
||||
|
||||
Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);
|
||||
let _ = self.write_claude_live(&live_config);
|
||||
}
|
||||
}
|
||||
@@ -1100,6 +1068,11 @@ impl ProxyService {
|
||||
|
||||
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
|
||||
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
||||
self.restore_live_config_for_app_inner(app_type).await
|
||||
}
|
||||
|
||||
async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
||||
@@ -1159,6 +1132,15 @@ impl ProxyService {
|
||||
async fn restore_live_config_for_app_with_fallback(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
|
||||
self.restore_live_config_for_app_with_fallback_inner(app_type)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restore_live_config_for_app_with_fallback_inner(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), String> {
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
@@ -1487,6 +1469,17 @@ impl ProxyService {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
self.update_live_backup_from_provider_inner(app_type, provider)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 仅供已持有 per-app 切换锁的调用方使用。
|
||||
async fn update_live_backup_from_provider_inner(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
||||
@@ -1540,6 +1533,71 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hot_switch_provider(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<HotSwitchOutcome, String> {
|
||||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||||
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
let provider = self
|
||||
.db
|
||||
.get_provider_by_id(provider_id, app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
|
||||
let logical_target_changed =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
|
||||
.map_err(|e| format!("读取当前供应商失败: {e}"))?
|
||||
.as_deref()
|
||||
!= Some(provider_id);
|
||||
|
||||
let has_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_enum.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
|
||||
.is_some();
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||
let should_sync_backup = has_backup || live_taken_over;
|
||||
|
||||
self.db
|
||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
if should_sync_backup {
|
||||
self.update_live_backup_from_provider_inner(app_type, &provider)
|
||||
.await?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Claude) {
|
||||
self.sync_claude_live_from_provider_while_proxy_active(&provider)
|
||||
.await?;
|
||||
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
|
||||
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(HotSwitchOutcome {
|
||||
logical_target_changed,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
||||
self.switch_locks.lock_for_app(app_type).await
|
||||
}
|
||||
|
||||
fn preserve_codex_mcp_servers_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
@@ -1607,47 +1665,13 @@ impl ProxyService {
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// 代理模式切换供应商(热切换):
|
||||
// - 更新 SSOT(数据库 is_current)
|
||||
// - 同步本地 settings(设备级 current_provider_*)
|
||||
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
let outcome = self.hot_switch_provider(app_type, provider_id).await?;
|
||||
|
||||
self.db
|
||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||
|
||||
// 同步本地 settings(设备级优先)
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
|
||||
let has_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_enum.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
|
||||
if has_backup || live_taken_over {
|
||||
self.update_live_backup_from_provider(app_type, &provider)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||
.await;
|
||||
}
|
||||
if outcome.logical_target_changed {
|
||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||
} else {
|
||||
log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
|
||||
}
|
||||
|
||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1924,6 +1948,7 @@ mod tests {
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
original_test_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
@@ -1931,14 +1956,17 @@ mod tests {
|
||||
let dir = TempDir::new().expect("failed to create temp home");
|
||||
let original_home = env::var("HOME").ok();
|
||||
let original_userprofile = env::var("USERPROFILE").ok();
|
||||
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
|
||||
|
||||
env::set_var("HOME", dir.path());
|
||||
env::set_var("USERPROFILE", dir.path());
|
||||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||||
|
||||
Self {
|
||||
dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
original_test_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1954,6 +1982,11 @@ mod tests {
|
||||
Some(value) => env::set_var("USERPROFILE", value),
|
||||
None => env::remove_var("USERPROFILE"),
|
||||
}
|
||||
|
||||
match &self.original_test_home {
|
||||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2193,6 +2226,287 @@ model = "gpt-5.1-codex"
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "a-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.a.example",
|
||||
"ANTHROPIC_MODEL": "claude-old"
|
||||
},
|
||||
"permissions": { "allow": ["Bash"] }
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "b-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.b.example",
|
||||
"ANTHROPIC_MODEL": "claude-new"
|
||||
},
|
||||
"permissions": { "allow": ["Read"] }
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup(
|
||||
"claude",
|
||||
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
|
||||
"ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_MODEL": "stale-model"
|
||||
},
|
||||
"permissions": { "allow": ["Bash"] }
|
||||
}))
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
service
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("hot switch provider");
|
||||
|
||||
let live = service.read_claude_live().expect("read live config");
|
||||
assert_eq!(
|
||||
live.get("permissions"),
|
||||
provider_b.settings_config.get("permissions"),
|
||||
"provider-derived live settings should be refreshed"
|
||||
);
|
||||
assert_eq!(
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER),
|
||||
"takeover token placeholder should be preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("http://127.0.0.1:15721"),
|
||||
"takeover proxy URL should remain active"
|
||||
);
|
||||
assert!(
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_MODEL"))
|
||||
.is_none(),
|
||||
"Claude model override fields should be removed in takeover mode"
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn hot_switch_provider_serializes_same_app_switches() {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_c = Provider::with_id(
|
||||
"c".to_string(),
|
||||
"C".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.save_provider("claude", &provider_c)
|
||||
.expect("save provider c");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup("claude", "{\"env\":{}}")
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
let guard = service.lock_switch_for_test("claude").await;
|
||||
let service_for_b = service.clone();
|
||||
let service_for_c = service.clone();
|
||||
|
||||
let switch_b = tokio::spawn(async move {
|
||||
service_for_b
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("switch to b")
|
||||
});
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
let switch_c = tokio::spawn(async move {
|
||||
service_for_c
|
||||
.hot_switch_provider("claude", "c")
|
||||
.await
|
||||
.expect("switch to c")
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
drop(guard);
|
||||
|
||||
let outcome_b = switch_b.await.expect("join switch b");
|
||||
let outcome_c = switch_c.await.expect("join switch c");
|
||||
assert!(outcome_b.logical_target_changed);
|
||||
assert!(outcome_c.logical_target_changed);
|
||||
|
||||
assert_eq!(
|
||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
||||
.expect("effective current"),
|
||||
Some("c".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
|
||||
Some("c")
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_current_provider("claude").expect("db current"),
|
||||
Some("c".to_string())
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn restore_waits_for_hot_switch_and_restores_latest_backup() {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup(
|
||||
"claude",
|
||||
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
service
|
||||
.write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } }))
|
||||
.expect("seed live file");
|
||||
|
||||
let guard = service.lock_switch_for_test("claude").await;
|
||||
let service_for_switch = service.clone();
|
||||
let service_for_restore = service.clone();
|
||||
|
||||
let switch_to_b = tokio::spawn(async move {
|
||||
service_for_switch
|
||||
.hot_switch_provider("claude", "b")
|
||||
.await
|
||||
.expect("switch to b")
|
||||
});
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
let restore = tokio::spawn(async move {
|
||||
service_for_restore
|
||||
.restore_live_config_for_app_with_fallback(&AppType::Claude)
|
||||
.await
|
||||
.expect("restore claude live")
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
drop(guard);
|
||||
|
||||
let outcome = switch_to_b.await.expect("join switch");
|
||||
restore.await.expect("join restore");
|
||||
assert!(outcome.logical_target_changed);
|
||||
|
||||
assert_eq!(
|
||||
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
|
||||
.expect("effective current"),
|
||||
Some("b".to_string())
|
||||
);
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
assert_eq!(
|
||||
service.read_claude_live().expect("read live"),
|
||||
provider_b.settings_config
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_applies_claude_common_config() {
|
||||
|
||||
@@ -204,10 +204,11 @@ pub async fn ensure_remote_directories(
|
||||
s if s == StatusCode::CREATED || s.is_success() => {
|
||||
log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url));
|
||||
}
|
||||
// 405 commonly means "already exists" on many WebDAV servers
|
||||
StatusCode::METHOD_NOT_ALLOWED => {}
|
||||
// Ambiguous — verify directory actually exists via PROPFIND
|
||||
s if s == StatusCode::CONFLICT || s.is_redirection() => {
|
||||
s if s == StatusCode::METHOD_NOT_ALLOWED
|
||||
|| s == StatusCode::CONFLICT
|
||||
|| s.is_redirection() =>
|
||||
{
|
||||
if !propfind_exists(&client, &dir_url, auth).await? {
|
||||
return Err(webdav_status_error("MKCOL", status, &dir_url));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod providers;
|
||||
pub mod terminal;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||
@@ -36,6 +36,25 @@ pub struct SessionMessage {
|
||||
pub ts: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteSessionRequest {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
pub source_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteSessionOutcome {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
pub source_path: String,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
||||
let h1 = s.spawn(codex::scan_sessions);
|
||||
@@ -99,6 +118,16 @@ pub fn delete_session(
|
||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||
}
|
||||
|
||||
pub fn delete_sessions(requests: &[DeleteSessionRequest]) -> Vec<DeleteSessionOutcome> {
|
||||
collect_delete_session_outcomes(requests, |request| {
|
||||
delete_session(
|
||||
&request.provider_id,
|
||||
&request.session_id,
|
||||
&request.source_path,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_session_with_root(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
@@ -147,6 +176,41 @@ fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, Strin
|
||||
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn collect_delete_session_outcomes<F>(
|
||||
requests: &[DeleteSessionRequest],
|
||||
mut deleter: F,
|
||||
) -> Vec<DeleteSessionOutcome>
|
||||
where
|
||||
F: FnMut(&DeleteSessionRequest) -> Result<bool, String>,
|
||||
{
|
||||
requests
|
||||
.iter()
|
||||
.map(|request| match deleter(request) {
|
||||
Ok(true) => DeleteSessionOutcome {
|
||||
provider_id: request.provider_id.clone(),
|
||||
session_id: request.session_id.clone(),
|
||||
source_path: request.source_path.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
},
|
||||
Ok(false) => DeleteSessionOutcome {
|
||||
provider_id: request.provider_id.clone(),
|
||||
session_id: request.session_id.clone(),
|
||||
source_path: request.source_path.clone(),
|
||||
success: false,
|
||||
error: Some("Session was not deleted".to_string()),
|
||||
},
|
||||
Err(error) => DeleteSessionOutcome {
|
||||
provider_id: request.provider_id.clone(),
|
||||
session_id: request.session_id.clone(),
|
||||
source_path: request.source_path.clone(),
|
||||
success: false,
|
||||
error: Some(error),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -175,4 +239,44 @@ mod tests {
|
||||
|
||||
assert!(err.contains("session source not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_delete_collects_successes_and_failures_in_order() {
|
||||
let requests = vec![
|
||||
DeleteSessionRequest {
|
||||
provider_id: "codex".to_string(),
|
||||
session_id: "s1".to_string(),
|
||||
source_path: "/tmp/s1".to_string(),
|
||||
},
|
||||
DeleteSessionRequest {
|
||||
provider_id: "claude".to_string(),
|
||||
session_id: "s2".to_string(),
|
||||
source_path: "/tmp/s2".to_string(),
|
||||
},
|
||||
DeleteSessionRequest {
|
||||
provider_id: "gemini".to_string(),
|
||||
session_id: "s3".to_string(),
|
||||
source_path: "/tmp/s3".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let outcomes = collect_delete_session_outcomes(&requests, |request| {
|
||||
match request.session_id.as_str() {
|
||||
"s1" => Ok(true),
|
||||
"s2" => Err("boom".to_string()),
|
||||
_ => Ok(false),
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(outcomes.len(), 3);
|
||||
assert!(outcomes[0].success);
|
||||
assert_eq!(outcomes[0].error, None);
|
||||
assert!(!outcomes[1].success);
|
||||
assert_eq!(outcomes[1].error.as_deref(), Some("boom"));
|
||||
assert!(!outcomes[2].success);
|
||||
assert_eq!(
|
||||
outcomes[2].error.as_deref(),
|
||||
Some("Session was not deleted")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,17 +584,14 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
/// 这是设备级别的设置,不随数据库同步。
|
||||
/// 传入 `None` 会清除当前供应商设置。
|
||||
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
|
||||
let mut settings = get_settings();
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
|
||||
}
|
||||
|
||||
update_settings(settings)
|
||||
let id_owned = id.map(|s| s.to_string());
|
||||
mutate_settings(|settings| match app_type {
|
||||
AppType::Claude => settings.current_provider_claude = id_owned.clone(),
|
||||
AppType::Codex => settings.current_provider_codex = id_owned.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取有效的当前供应商 ID(验证存在性)
|
||||
|
||||
@@ -393,11 +393,10 @@ pub fn create_tray_menu(
|
||||
true,
|
||||
crate::lightweight::is_lightweight_mode(),
|
||||
None::<&str>,
|
||||
).map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
|
||||
|
||||
menu_builder = menu_builder
|
||||
.item(&lightweight_item)
|
||||
.separator();
|
||||
menu_builder = menu_builder.item(&lightweight_item).separator();
|
||||
|
||||
// 退出菜单(分隔符已在上面的 section 循环中添加)
|
||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||
@@ -472,10 +471,8 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
||||
log::error!("退出轻量模式失败: {e}");
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
|
||||
log::error!("进入轻量模式失败: {e}");
|
||||
}
|
||||
} else if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
|
||||
log::error!("进入轻量模式失败: {e}");
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
|
||||
+49
-7
@@ -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) {
|
||||
@@ -648,7 +683,14 @@ function App() {
|
||||
|
||||
const handleOpenTerminal = async (provider: Provider) => {
|
||||
try {
|
||||
await providersApi.openTerminal(provider.id, activeApp);
|
||||
const selectedDir = await settingsApi.pickDirectory();
|
||||
if (!selectedDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
await providersApi.openTerminal(provider.id, activeApp, {
|
||||
cwd: selectedDir,
|
||||
});
|
||||
toast.success(
|
||||
t("provider.terminalOpened", {
|
||||
defaultValue: "终端已打开",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -123,6 +123,7 @@ export function ProviderCard({
|
||||
// OMO and OMO Slim share the same card behavior
|
||||
const isAnyOmo = isOmo || isOmoSlim;
|
||||
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
||||
const isAdditiveMode = appId === "opencode" && !isAnyOmo;
|
||||
|
||||
const { data: health } = useProviderHealth(provider.id, appId);
|
||||
|
||||
@@ -209,9 +210,12 @@ export function ProviderCard({
|
||||
: isCurrent;
|
||||
|
||||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||
const hasPersistentConfigHighlight = isAdditiveMode && isInConfig;
|
||||
const shouldUseBlue =
|
||||
(isAnyOmo && isActiveProvider) ||
|
||||
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
|
||||
(!isAnyOmo &&
|
||||
!isProxyTakeover &&
|
||||
(isActiveProvider || hasPersistentConfigHighlight));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -224,7 +228,8 @@ export function ProviderCard({
|
||||
shouldUseGreen &&
|
||||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||
!isActiveProvider && "hover:shadow-sm",
|
||||
!(isActiveProvider || hasPersistentConfigHighlight) &&
|
||||
"hover:shadow-sm",
|
||||
dragHandleProps?.isDragging &&
|
||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||
)}
|
||||
@@ -234,8 +239,10 @@ export function ProviderCard({
|
||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||
shouldUseGreen && "from-emerald-500/10",
|
||||
shouldUseBlue && "from-blue-500/10",
|
||||
!isActiveProvider && "from-primary/10",
|
||||
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||
!shouldUseGreen && !shouldUseBlue && "from-primary/10",
|
||||
isActiveProvider || hasPersistentConfigHighlight
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ProviderPresetSelector({
|
||||
case "omo":
|
||||
return t("providerForm.omoHint", {
|
||||
defaultValue:
|
||||
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||
"💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
|
||||
});
|
||||
default:
|
||||
return t("providerPreset.hint", {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronRight, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -19,13 +20,21 @@ import {
|
||||
interface SessionItemProps {
|
||||
session: SessionMeta;
|
||||
isSelected: boolean;
|
||||
selectionMode: boolean;
|
||||
isChecked: boolean;
|
||||
isCheckDisabled?: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
onToggleChecked: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function SessionItem({
|
||||
session,
|
||||
isSelected,
|
||||
selectionMode,
|
||||
isChecked,
|
||||
isCheckDisabled = false,
|
||||
onSelect,
|
||||
onToggleChecked,
|
||||
}: SessionItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const title = formatSessionTitle(session);
|
||||
@@ -33,46 +42,64 @@ export function SessionItem({
|
||||
const sessionKey = getSessionKey(session);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||
"flex items-start gap-2 rounded-lg px-3 py-2.5 transition-all group",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/60 border border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className="shrink-0 pt-0.5">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
disabled={isCheckDisabled}
|
||||
aria-label={t("sessionManager.selectForBatch", {
|
||||
defaultValue: "选择会话",
|
||||
})}
|
||||
onCheckedChange={(checked) => onToggleChecked(Boolean(checked))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(sessionKey)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0">
|
||||
<ProviderIcon
|
||||
icon={getProviderIconName(session.providerId)}
|
||||
name={session.providerId}
|
||||
size={18}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{getProviderLabel(session.providerId, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium truncate flex-1">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||
isSelected && "text-primary rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive ? formatRelativeTime(lastActive, t) : t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock className="size-3" />
|
||||
<span>
|
||||
{lastActive
|
||||
? formatRelativeTime(lastActive, t)
|
||||
: t("common.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSessionSearch } from "@/hooks/useSessionSearch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Copy,
|
||||
RefreshCw,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Clock,
|
||||
FolderOpen,
|
||||
X,
|
||||
CheckSquare,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteSessionMutation,
|
||||
@@ -63,6 +65,7 @@ type ProviderFilter =
|
||||
|
||||
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, refetch } = useSessionsQuery();
|
||||
const sessions = data ?? [];
|
||||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -73,7 +76,14 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
);
|
||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
|
||||
const [deleteTargets, setDeleteTargets] = useState<SessionMeta[] | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedSessionKeys, setSelectedSessionKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [isBatchDeleting, setIsBatchDeleting] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -122,6 +132,25 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
selectedSession?.sourcePath,
|
||||
);
|
||||
const deleteSessionMutation = useDeleteSessionMutation();
|
||||
const isDeleting = deleteSessionMutation.isPending || isBatchDeleting;
|
||||
|
||||
useEffect(() => {
|
||||
const validKeys = new Set(
|
||||
sessions.map((session) => getSessionKey(session)),
|
||||
);
|
||||
setSelectedSessionKeys((current) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
current.forEach((key) => {
|
||||
if (validKeys.has(key)) {
|
||||
next.add(key);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
// 提取用户消息用于目录
|
||||
const userMessagesToc = useMemo(() => {
|
||||
@@ -194,16 +223,195 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
|
||||
if (!deleteTargets || deleteTargets.length === 0 || isDeleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: deleteTarget.providerId,
|
||||
sessionId: deleteTarget.sessionId,
|
||||
sourcePath: deleteTarget.sourcePath,
|
||||
const targets = deleteTargets.filter((session) => session.sourcePath);
|
||||
setDeleteTargets(null);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 1) {
|
||||
const [target] = targets;
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: target.providerId,
|
||||
sessionId: target.sessionId,
|
||||
sourcePath: target.sourcePath!,
|
||||
});
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(getSessionKey(target));
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchDeleting(true);
|
||||
try {
|
||||
const results = await sessionsApi.deleteMany(
|
||||
targets.map((session) => ({
|
||||
providerId: session.providerId,
|
||||
sessionId: session.sessionId,
|
||||
sourcePath: session.sourcePath!,
|
||||
})),
|
||||
);
|
||||
|
||||
const deletedKeys = results
|
||||
.filter((result) => result.success)
|
||||
.map(
|
||||
(result) =>
|
||||
`${result.providerId}:${result.sessionId}:${result.sourcePath ?? ""}`,
|
||||
);
|
||||
|
||||
const failedErrors = results
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error || t("common.unknown"));
|
||||
|
||||
if (deletedKeys.length > 0) {
|
||||
const deletedKeySet = new Set(deletedKeys);
|
||||
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
|
||||
(current ?? []).filter(
|
||||
(session) => !deletedKeySet.has(getSessionKey(session)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
results
|
||||
.filter((result) => result.success)
|
||||
.forEach((result) => {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["sessionMessages", result.providerId, result.sourcePath],
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
deletedKeys.forEach((key) => next.delete(key));
|
||||
return next;
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
|
||||
if (deletedKeys.length > 0) {
|
||||
toast.success(
|
||||
t("sessionManager.batchDeleteSuccess", {
|
||||
defaultValue: "已删除 {{count}} 个会话",
|
||||
count: deletedKeys.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (failedErrors.length > 0) {
|
||||
toast.error(
|
||||
t("sessionManager.batchDeleteFailed", {
|
||||
defaultValue: "{{failed}} 个会话删除失败",
|
||||
failed: failedErrors.length,
|
||||
}),
|
||||
{
|
||||
description: failedErrors[0],
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
extractErrorMessage(error) ||
|
||||
t("sessionManager.batchDeleteRequestFailed", {
|
||||
defaultValue: "批量删除失败,请稍后重试",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsBatchDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletableFilteredSessions = useMemo(
|
||||
() => filteredSessions.filter((session) => Boolean(session.sourcePath)),
|
||||
[filteredSessions],
|
||||
);
|
||||
|
||||
const selectedSessions = useMemo(
|
||||
() =>
|
||||
sessions.filter((session) =>
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
),
|
||||
[sessions, selectedSessionKeys],
|
||||
);
|
||||
|
||||
const selectedDeletableSessions = useMemo(
|
||||
() => selectedSessions.filter((session) => Boolean(session.sourcePath)),
|
||||
[selectedSessions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionMode) return;
|
||||
|
||||
const visibleKeys = new Set(
|
||||
deletableFilteredSessions.map((session) => getSessionKey(session)),
|
||||
);
|
||||
|
||||
setSelectedSessionKeys((current) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
|
||||
current.forEach((key) => {
|
||||
if (visibleKeys.has(key)) {
|
||||
next.add(key);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [deletableFilteredSessions, selectionMode]);
|
||||
|
||||
const allFilteredSelected =
|
||||
deletableFilteredSessions.length > 0 &&
|
||||
deletableFilteredSessions.every((session) =>
|
||||
selectedSessionKeys.has(getSessionKey(session)),
|
||||
);
|
||||
|
||||
const toggleSessionChecked = (session: SessionMeta, checked: boolean) => {
|
||||
if (!session.sourcePath) return;
|
||||
const key = getSessionKey(session);
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelectAll = () => {
|
||||
setSelectedSessionKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (allFilteredSelected) {
|
||||
deletableFilteredSessions.forEach((session) =>
|
||||
next.delete(getSessionKey(session)),
|
||||
);
|
||||
} else {
|
||||
deletableFilteredSessions.forEach((session) =>
|
||||
next.add(getSessionKey(session)),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const openBatchDeleteDialog = () => {
|
||||
if (selectedDeletableSessions.length === 0) return;
|
||||
setDeleteTargets(selectedDeletableSessions);
|
||||
};
|
||||
|
||||
const exitSelectionMode = () => {
|
||||
setSelectionMode(false);
|
||||
setSelectedSessionKeys(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -219,174 +427,318 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
<Card className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<CardHeader className="py-2 px-3 border-b">
|
||||
{isSearchOpen ? (
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("sessionManager.searchPlaceholder")}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (search.trim() === "") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectionMode && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
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: "退出批量管理",
|
||||
},
|
||||
)}
|
||||
onClick={exitSelectionMode}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
<CheckSquare className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.searchSessions")}
|
||||
{t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CardTitle className="text-sm font-medium whitespace-nowrap">
|
||||
{t("sessionManager.sessionList")}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredSessions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{(selectionMode ||
|
||||
deletableFilteredSessions.length > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={selectionMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className={
|
||||
selectionMode
|
||||
? "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"
|
||||
: "size-7"
|
||||
}
|
||||
aria-label={
|
||||
selectionMode
|
||||
? t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})
|
||||
: t("sessionManager.manageBatchTooltip", {
|
||||
defaultValue: "批量管理",
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckSquare className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectionMode
|
||||
? t("sessionManager.exitBatchModeTooltip", {
|
||||
defaultValue: "退出批量管理",
|
||||
})
|
||||
: t("sessionManager.manageBatchTooltip", {
|
||||
defaultValue: "批量管理",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: getProviderIconName(providerFilter)
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
setTimeout(
|
||||
() => searchInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
{t("sessionManager.searchSessions")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon icon="apps" name="all" size={14} />
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="opencode">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="opencode"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="openclaw">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openclaw"
|
||||
name="openclaw"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={(value) =>
|
||||
setProviderFilter(value as ProviderFilter)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="size-7 p-0 justify-center border-0 bg-transparent hover:bg-muted">
|
||||
<ProviderIcon
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: getProviderIconName(providerFilter)
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{providerFilter === "all"
|
||||
? t("sessionManager.providerFilterAll")
|
||||
: providerFilter}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="apps"
|
||||
name="all"
|
||||
size={14}
|
||||
/>
|
||||
<span>
|
||||
{t("sessionManager.providerFilterAll")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openai"
|
||||
name="codex"
|
||||
size={14}
|
||||
/>
|
||||
<span>Codex</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="claude"
|
||||
name="claude"
|
||||
size={14}
|
||||
/>
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="opencode">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="opencode"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="openclaw">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openclaw"
|
||||
name="openclaw"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{selectionMode && (
|
||||
<div className="grid gap-3 rounded-md border bg-muted/40 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("sessionManager.selectedCount", {
|
||||
defaultValue: "已选 {{count}} 项",
|
||||
count: selectedDeletableSessions.length,
|
||||
})}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{t("sessionManager.batchModeHint", {
|
||||
defaultValue: "勾选要删除的会话",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 min-[520px]:grid-cols-[minmax(0,1fr)_auto] min-[520px]:items-center">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{deletableFilteredSessions.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
||||
onClick={handleToggleSelectAll}
|
||||
>
|
||||
{allFilteredSelected
|
||||
? t("sessionManager.clearFilteredSelection", {
|
||||
defaultValue: "取消全选",
|
||||
})
|
||||
: t("sessionManager.selectAllFiltered", {
|
||||
defaultValue: "全选当前",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs whitespace-nowrap"
|
||||
onClick={() => setSelectedSessionKeys(new Set())}
|
||||
>
|
||||
{t("sessionManager.clearSelection", {
|
||||
defaultValue: "清空已选",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2.5 whitespace-nowrap justify-self-start min-[520px]:justify-self-end"
|
||||
onClick={openBatchDeleteDialog}
|
||||
disabled={
|
||||
isDeleting ||
|
||||
selectedDeletableSessions.length === 0
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="text-xs">
|
||||
{isBatchDeleting
|
||||
? t("sessionManager.batchDeleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
: t("sessionManager.deleteSelected", {
|
||||
defaultValue: "批量删除",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -416,7 +768,15 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
key={getSessionKey(session)}
|
||||
session={session}
|
||||
isSelected={isSelected}
|
||||
selectionMode={selectionMode}
|
||||
isChecked={selectedSessionKeys.has(
|
||||
getSessionKey(session),
|
||||
)}
|
||||
isCheckDisabled={!session.sourcePath}
|
||||
onSelect={setSelectedKey}
|
||||
onToggleChecked={(checked) =>
|
||||
toggleSessionChecked(session, checked)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -548,15 +908,16 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1.5"
|
||||
onClick={() => setDeleteTarget(selectedSession)}
|
||||
onClick={() =>
|
||||
setDeleteTargets([selectedSession])
|
||||
}
|
||||
disabled={
|
||||
!selectedSession.sourcePath ||
|
||||
deleteSessionMutation.isPending
|
||||
!selectedSession.sourcePath || isDeleting
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{deleteSessionMutation.isPending
|
||||
{isDeleting
|
||||
? t("sessionManager.deleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
@@ -685,29 +1046,47 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={Boolean(deleteTarget)}
|
||||
title={t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
message={
|
||||
deleteTarget
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTarget),
|
||||
sessionId: deleteTarget.sessionId,
|
||||
isOpen={Boolean(deleteTargets)}
|
||||
title={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmTitle", {
|
||||
defaultValue: "批量删除会话",
|
||||
})
|
||||
: t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})
|
||||
}
|
||||
message={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||
count: deleteTargets.length,
|
||||
})
|
||||
: deleteTargets?.[0]
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTargets[0]),
|
||||
sessionId: deleteTargets[0].sessionId,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={
|
||||
deleteTargets && deleteTargets.length > 1
|
||||
? t("sessionManager.batchDeleteConfirmAction", {
|
||||
defaultValue: "删除所选会话",
|
||||
})
|
||||
: t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
||||
variant="destructive"
|
||||
onConfirm={() => void handleDeleteConfirm()}
|
||||
onCancel={() => {
|
||||
if (!deleteSessionMutation.isPending) {
|
||||
setDeleteTarget(null);
|
||||
if (!isDeleting) {
|
||||
setDeleteTargets(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -98,6 +98,20 @@ function formatDbCompatVersion(version?: number | null): string | null {
|
||||
return typeof version === "number" ? `db-v${version}` : null;
|
||||
}
|
||||
|
||||
function buildPasswordPreservationKey(values: {
|
||||
baseUrl?: string | null;
|
||||
username?: string | null;
|
||||
remoteRoot?: string | null;
|
||||
profile?: string | null;
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
baseUrl: values.baseUrl ?? "",
|
||||
username: values.username ?? "",
|
||||
remoteRoot: values.remoteRoot ?? "cc-switch-sync",
|
||||
profile: values.profile ?? "default",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
type ActionState =
|
||||
@@ -167,6 +181,10 @@ export function WebdavSyncSection({
|
||||
const [passwordTouched, setPasswordTouched] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingPasswordPreservationRef = useRef<{
|
||||
key: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
// Local form state — credentials are only persisted on explicit "Save".
|
||||
const [form, setForm] = useState(() => ({
|
||||
@@ -205,13 +223,36 @@ export function WebdavSyncSection({
|
||||
// Sync form when config is loaded/updated from backend, but not while user is editing
|
||||
useEffect(() => {
|
||||
if (!config || dirty) return;
|
||||
setForm({
|
||||
baseUrl: config.baseUrl ?? "",
|
||||
username: config.username ?? "",
|
||||
password: config.password ?? "",
|
||||
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
|
||||
profile: config.profile ?? "default",
|
||||
autoSync: config.autoSync ?? false,
|
||||
setForm(() => {
|
||||
const nextBaseUrl = config.baseUrl ?? "";
|
||||
const nextUsername = config.username ?? "";
|
||||
const nextRemoteRoot = config.remoteRoot ?? "cc-switch-sync";
|
||||
const nextProfile = config.profile ?? "default";
|
||||
const nextKey = buildPasswordPreservationKey({
|
||||
baseUrl: nextBaseUrl,
|
||||
username: nextUsername,
|
||||
remoteRoot: nextRemoteRoot,
|
||||
profile: nextProfile,
|
||||
});
|
||||
const shouldPreserveRedactedPassword =
|
||||
!config.password &&
|
||||
pendingPasswordPreservationRef.current?.key === nextKey &&
|
||||
!!pendingPasswordPreservationRef.current.password;
|
||||
|
||||
const nextPassword = shouldPreserveRedactedPassword
|
||||
? pendingPasswordPreservationRef.current!.password
|
||||
: (config.password ?? "");
|
||||
|
||||
pendingPasswordPreservationRef.current = null;
|
||||
|
||||
return {
|
||||
baseUrl: nextBaseUrl,
|
||||
username: nextUsername,
|
||||
password: nextPassword,
|
||||
remoteRoot: nextRemoteRoot,
|
||||
profile: nextProfile,
|
||||
autoSync: config.autoSync ?? false,
|
||||
};
|
||||
});
|
||||
setPasswordTouched(false);
|
||||
setPresetId(detectPreset(config.baseUrl ?? ""));
|
||||
@@ -289,12 +330,13 @@ export function WebdavSyncSection({
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
// 未重新触碰密码时,提交空值让后端沿用已保存密码,表单里的值仅用于 UI 显示
|
||||
password: passwordTouched ? form.password : "",
|
||||
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
|
||||
profile: form.profile.trim() || "default",
|
||||
autoSync: form.autoSync,
|
||||
};
|
||||
}, [form]);
|
||||
}, [form, passwordTouched]);
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────
|
||||
|
||||
@@ -326,6 +368,12 @@ export function WebdavSyncSection({
|
||||
return;
|
||||
}
|
||||
setActionState("saving");
|
||||
pendingPasswordPreservationRef.current = form.password
|
||||
? {
|
||||
key: buildPasswordPreservationKey(settings),
|
||||
password: form.password,
|
||||
}
|
||||
: null;
|
||||
try {
|
||||
await settingsApi.webdavSyncSaveSettings(settings, passwordTouched);
|
||||
setDirty(false);
|
||||
@@ -339,6 +387,7 @@ export function WebdavSyncSection({
|
||||
}, 2000);
|
||||
await queryClient.invalidateQueries();
|
||||
} catch (error) {
|
||||
pendingPasswordPreservationRef.current = null;
|
||||
toast.error(
|
||||
t("settings.webdavSync.saveFailed", {
|
||||
error: (error as Error)?.message ?? String(error),
|
||||
@@ -362,7 +411,7 @@ export function WebdavSyncSection({
|
||||
} finally {
|
||||
setActionState("idle");
|
||||
}
|
||||
}, [buildSettings, passwordTouched, queryClient, t]);
|
||||
}, [buildSettings, form.password, passwordTouched, queryClient, t]);
|
||||
|
||||
/** Fetch remote info, then open upload confirmation dialog. */
|
||||
const handleUploadClick = useCallback(async () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
|
||||
{
|
||||
name: "Oh My OpenCode",
|
||||
websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode",
|
||||
websiteUrl: "https://github.com/code-yeongyu/oh-my-openagent",
|
||||
settingsConfig: {
|
||||
npm: "",
|
||||
options: {},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
"sessionList": "Sessions",
|
||||
"manageBatchTooltip": "Enter batch management",
|
||||
"exitBatchModeTooltip": "Exit batch management",
|
||||
"batchModeHint": "Select sessions to delete",
|
||||
"selectForBatch": "Select session",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectAllFiltered": "Select all",
|
||||
"clearFilteredSelection": "Clear selection",
|
||||
"clearSelection": "Clear",
|
||||
"deleteSelected": "Delete",
|
||||
"batchDeleting": "Deleting...",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"noSessions": "No sessions found",
|
||||
"selectSession": "Select a session to view details",
|
||||
@@ -641,6 +651,12 @@
|
||||
"deleteConfirmAction": "Delete session",
|
||||
"sessionDeleted": "Session deleted",
|
||||
"deleteFailed": "Failed to delete session: {{error}}",
|
||||
"batchDeleteConfirmTitle": "Delete selected sessions",
|
||||
"batchDeleteConfirmMessage": "This will permanently delete {{count}} selected local sessions.\n\nThis action cannot be undone.",
|
||||
"batchDeleteConfirmAction": "Delete selected",
|
||||
"batchDeleteSuccess": "Deleted {{count}} sessions",
|
||||
"batchDeleteFailed": "{{failed}} sessions could not be deleted",
|
||||
"batchDeleteRequestFailed": "Batch delete failed. Please try again later.",
|
||||
"loadingMessages": "Loading transcript...",
|
||||
"emptySession": "No messages available",
|
||||
"clickToCopyPath": "Click to copy path",
|
||||
@@ -698,7 +714,7 @@
|
||||
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
|
||||
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
|
||||
"omoHint": "💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc",
|
||||
"omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc",
|
||||
"officialHint": "💡 Official provider uses browser login, no API Key needed",
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
@@ -917,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.",
|
||||
@@ -1376,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.",
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
"sessionList": "セッション一覧",
|
||||
"manageBatchTooltip": "一括管理に入る",
|
||||
"exitBatchModeTooltip": "一括管理を終了",
|
||||
"batchModeHint": "削除するセッションを選択",
|
||||
"selectForBatch": "セッションを選択",
|
||||
"selectedCount": "{{count}} 件を選択中",
|
||||
"selectAllFiltered": "一覧を全選択",
|
||||
"clearFilteredSelection": "全選択を解除",
|
||||
"clearSelection": "クリア",
|
||||
"deleteSelected": "削除",
|
||||
"batchDeleting": "削除中...",
|
||||
"loadingSessions": "セッションを読み込み中...",
|
||||
"noSessions": "セッションが見つかりません",
|
||||
"selectSession": "セッションを選択してください",
|
||||
@@ -641,6 +651,12 @@
|
||||
"deleteConfirmAction": "セッションを削除",
|
||||
"sessionDeleted": "セッションを削除しました",
|
||||
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
||||
"batchDeleteConfirmTitle": "選択したセッションを削除",
|
||||
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
|
||||
"batchDeleteConfirmAction": "選択した項目を削除",
|
||||
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
|
||||
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
|
||||
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
|
||||
"loadingMessages": "内容を読み込み中...",
|
||||
"emptySession": "表示できる内容がありません",
|
||||
"clickToCopyPath": "クリックしてパスをコピー",
|
||||
@@ -698,7 +714,7 @@
|
||||
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
||||
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
|
||||
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
|
||||
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
|
||||
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
|
||||
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
|
||||
"getApiKey": "API Key を取得",
|
||||
"partnerPromotion": {
|
||||
@@ -917,7 +933,8 @@
|
||||
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
||||
@@ -1376,7 +1393,8 @@
|
||||
"backupCreated": "バックアップを作成しました: {{path}}",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
"sessionList": "会话列表",
|
||||
"manageBatchTooltip": "进入批量管理",
|
||||
"exitBatchModeTooltip": "退出批量管理",
|
||||
"batchModeHint": "勾选要删除的会话",
|
||||
"selectForBatch": "选择会话",
|
||||
"selectedCount": "已选 {{count}} 项",
|
||||
"selectAllFiltered": "全选当前",
|
||||
"clearFilteredSelection": "取消全选",
|
||||
"clearSelection": "清空已选",
|
||||
"deleteSelected": "批量删除",
|
||||
"batchDeleting": "删除中...",
|
||||
"loadingSessions": "加载会话中...",
|
||||
"noSessions": "未发现会话",
|
||||
"selectSession": "请选择会话查看详情",
|
||||
@@ -641,6 +651,12 @@
|
||||
"deleteConfirmAction": "删除会话",
|
||||
"sessionDeleted": "会话已删除",
|
||||
"deleteFailed": "删除会话失败: {{error}}",
|
||||
"batchDeleteConfirmTitle": "批量删除会话",
|
||||
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||
"batchDeleteConfirmAction": "删除所选会话",
|
||||
"batchDeleteSuccess": "已删除 {{count}} 个会话",
|
||||
"batchDeleteFailed": "{{failed}} 个会话删除失败",
|
||||
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
|
||||
"loadingMessages": "加载会话内容中...",
|
||||
"emptySession": "该会话暂无可展示内容",
|
||||
"clickToCopyPath": "点击复制路径",
|
||||
@@ -698,7 +714,7 @@
|
||||
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
|
||||
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
|
||||
"omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
|
||||
"omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
|
||||
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
@@ -917,7 +933,8 @@
|
||||
"modelsRequired": "请至少添加一个模型配置",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
@@ -1376,7 +1393,8 @@
|
||||
"backupCreated": "已创建备份:{{path}}",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface SwitchResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface OpenTerminalOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export const providersApi = {
|
||||
async getAll(appId: AppId): Promise<Record<string, Provider>> {
|
||||
return await invoke("get_providers", { app: appId });
|
||||
@@ -30,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> {
|
||||
@@ -83,8 +99,17 @@ export const providersApi = {
|
||||
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
|
||||
* 终端会使用该提供商特定的 API 配置,不影响全局设置
|
||||
*/
|
||||
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
|
||||
return await invoke("open_provider_terminal", { providerId, app: appId });
|
||||
async openTerminal(
|
||||
providerId: string,
|
||||
appId: AppId,
|
||||
options?: OpenTerminalOptions,
|
||||
): Promise<boolean> {
|
||||
const { cwd } = options ?? {};
|
||||
return await invoke("open_provider_terminal", {
|
||||
providerId,
|
||||
app: appId,
|
||||
cwd,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,11 @@ export interface DeleteSessionOptions {
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface DeleteSessionResult extends DeleteSessionOptions {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const sessionsApi = {
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
return await invoke("list_sessions");
|
||||
@@ -28,6 +33,12 @@ export const sessionsApi = {
|
||||
});
|
||||
},
|
||||
|
||||
async deleteMany(
|
||||
items: DeleteSessionOptions[],
|
||||
): Promise<DeleteSessionResult[]> {
|
||||
return await invoke("delete_sessions", { items });
|
||||
},
|
||||
|
||||
async launchTerminal(options: {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
|
||||
@@ -47,6 +47,10 @@ export const settingsApi = {
|
||||
await invoke("open_config_folder", { app: appId });
|
||||
},
|
||||
|
||||
async pickDirectory(defaultPath?: string): Promise<string | null> {
|
||||
return await invoke("pick_directory", { defaultPath });
|
||||
},
|
||||
|
||||
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
|
||||
return await invoke("pick_directory", { defaultPath });
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ export const OMO_DISABLEABLE_SKILLS = [
|
||||
] as const;
|
||||
|
||||
export const OMO_DEFAULT_SCHEMA_URL =
|
||||
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json";
|
||||
"https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
|
||||
|
||||
export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
|
||||
"disabled": false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import { sessionsApi } from "@/lib/api/sessions";
|
||||
import type { SessionMessage, SessionMeta } from "@/types";
|
||||
import { setSessionFixtures } from "../msw/state";
|
||||
|
||||
@@ -62,16 +64,19 @@ const renderPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionManagerPage appId="codex" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
client,
|
||||
...render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionManagerPage appId="codex" />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const openSearch = () => {
|
||||
const searchButton = Array.from(screen.getAllByRole("button")).find((button) =>
|
||||
button.querySelector(".lucide-search"),
|
||||
const searchButton = Array.from(screen.getAllByRole("button")).find(
|
||||
(button) => button.querySelector(".lucide-search"),
|
||||
);
|
||||
|
||||
if (!searchButton) {
|
||||
@@ -81,10 +86,23 @@ const openSearch = () => {
|
||||
fireEvent.click(searchButton);
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
const closeButton = Array.from(screen.getAllByRole("button")).find(
|
||||
(button) => button.querySelector(".lucide-x"),
|
||||
);
|
||||
|
||||
if (!closeButton) {
|
||||
throw new Error("Search close button not found");
|
||||
}
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
};
|
||||
|
||||
describe("SessionManagerPage", () => {
|
||||
beforeEach(() => {
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{
|
||||
@@ -178,11 +196,136 @@ describe("SessionManagerPage", () => {
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("sessionManager.selectSession"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("sessionManager.emptySession"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores batch delete controls when deleteMany rejects", async () => {
|
||||
const deleteManySpy = vi
|
||||
.spyOn(sessionsApi, "deleteMany")
|
||||
.mockRejectedValueOnce(new Error("network error"));
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastErrorMock).toHaveBeenCalledWith("network error"),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: /批量删除/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
deleteManySpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps the exit batch mode button visible when search hides all sessions", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
openSearch();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "NoSuchSession" },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
|
||||
|
||||
expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("drops hidden selections when search narrows the result set", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
|
||||
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
|
||||
|
||||
openSearch();
|
||||
fireEvent.change(screen.getByRole("textbox"), {
|
||||
target: { value: "Alpha" },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
closeSearch();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes successfully deleted sessions from the UI before refetch completes", async () => {
|
||||
const view = renderPage();
|
||||
let resolveInvalidate!: () => void;
|
||||
const invalidateSpy = vi
|
||||
.spyOn(view.client, "invalidateQueries")
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveInvalidate = () => resolve(undefined);
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveInvalidate();
|
||||
});
|
||||
invalidateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,11 +104,12 @@ function renderSection(config?: WebDavSyncSettings) {
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WebdavSyncSection config={config} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, client };
|
||||
}
|
||||
|
||||
describe("WebdavSyncSection", () => {
|
||||
@@ -204,7 +205,7 @@ describe("WebdavSyncSection", () => {
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://dav.example.com/dav/",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
password: "",
|
||||
autoSync: false,
|
||||
}),
|
||||
false,
|
||||
@@ -222,6 +223,111 @@ describe("WebdavSyncSection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves password only for the single post-save refresh", async () => {
|
||||
const view = renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("secret");
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("does not preserve password after a later external config refresh", async () => {
|
||||
const view = renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("secret");
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection
|
||||
config={{ ...baseConfig, username: "bob", password: "" }}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("does not submit a preserved password again when testing without touching it", async () => {
|
||||
const view = renderSection(baseConfig);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.test" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApiMock.webdavTestConnection).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
password: "",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves auto sync as true after toggle", async () => {
|
||||
renderSection(baseConfig);
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -129,6 +144,29 @@ export const handlers = [
|
||||
return success(deleteSession(providerId, sessionId, sourcePath));
|
||||
}),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/delete_sessions`, async ({ request }) => {
|
||||
const { items = [] } = await withJson<{
|
||||
items?: {
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
sourcePath: string;
|
||||
}[];
|
||||
}>(request);
|
||||
|
||||
return success(
|
||||
items.map((item) => ({
|
||||
providerId: item.providerId,
|
||||
sessionId: item.sessionId,
|
||||
sourcePath: item.sourcePath,
|
||||
success: deleteSession(
|
||||
item.providerId,
|
||||
item.sessionId,
|
||||
item.sourcePath,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}),
|
||||
|
||||
// MCP APIs
|
||||
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
@@ -174,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