mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
40 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 | |||
| 67e074c0a7 | |||
| b1c7fe5563 | |||
| 210bff96c2 | |||
| 3b33e6921b | |||
| 8d5f72757e | |||
| 8cae7b7b73 |
@@ -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: `{}`).
|
||||
|
||||
Generated
+90
-39
@@ -137,18 +137,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
@@ -324,6 +312,28 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@@ -496,6 +506,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor 4.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -504,7 +525,17 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
"brotli-decompressor 5.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "4.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -691,11 +722,19 @@ dependencies = [
|
||||
"auto-launch",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"brotli 7.0.0",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"httparse",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"indexmap 2.13.0",
|
||||
"json-five",
|
||||
"json5",
|
||||
@@ -708,6 +747,8 @@ dependencies = [
|
||||
"rquickjs",
|
||||
"rusqlite",
|
||||
"rust_decimal",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -726,6 +767,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"toml 0.8.23",
|
||||
"toml_edit 0.22.27",
|
||||
"tower 0.4.13",
|
||||
@@ -733,6 +775,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"webpki-roots 0.26.11",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -800,6 +843,15 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -810,23 +862,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1573,6 +1608,12 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -2206,12 +2247,14 @@ dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4200,7 +4243,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4410,6 +4453,8 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4473,6 +4518,7 @@ version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -4715,6 +4761,7 @@ version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -5325,7 +5372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
"brotli 8.0.2",
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
@@ -5613,7 +5660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
"brotli 8.0.2",
|
||||
"cargo_metadata",
|
||||
"ctor",
|
||||
"dunce",
|
||||
@@ -6017,18 +6064,13 @@ version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -6564,6 +6606,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
|
||||
+14
-2
@@ -23,7 +23,7 @@ test-hooks = []
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -38,7 +38,9 @@ tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks", "gzip"] }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
flate2 = "1"
|
||||
brotli = "7"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
@@ -47,6 +49,16 @@ axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] }
|
||||
hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] }
|
||||
http = "1"
|
||||
http-body = "1"
|
||||
http-body-util = "0.1"
|
||||
httparse = "1"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = "0.23"
|
||||
webpki-roots = "0.26"
|
||||
rustls-native-certs = "0.8"
|
||||
regex = "1.10"
|
||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||
thiserror = "2.0"
|
||||
|
||||
@@ -141,7 +141,7 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||
///
|
||||
/// Supported fields:
|
||||
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
|
||||
/// otherwise falls back to top-level `base_url`.
|
||||
/// otherwise falls back to top-level `base_url`.
|
||||
/// - `"model"`: writes to top-level `model` field.
|
||||
///
|
||||
/// Empty value removes the field.
|
||||
|
||||
@@ -49,7 +49,7 @@ pub async fn copilot_poll_for_auth(
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {}", e);
|
||||
log::error!("[CopilotAuth] 轮询失败: {e}");
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ pub async fn copilot_poll_for_account(
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {}", e);
|
||||
log::error!("[CopilotAuth] 轮询失败: {e}");
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#[tauri::command]
|
||||
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
||||
crate::lightweight::enter_lightweight_mode(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
|
||||
crate::lightweight::exit_lightweight_mode(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_lightweight_mode() -> bool {
|
||||
crate::lightweight::is_lightweight_mode()
|
||||
}
|
||||
+191
-16
@@ -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;
|
||||
@@ -500,7 +500,8 @@ fn extend_from_path_list(
|
||||
|
||||
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README):
|
||||
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
|
||||
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||||
/// 额外扫描 Bun 默认全局安装路径(~/.bun/bin)
|
||||
/// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||||
fn opencode_extra_search_paths(
|
||||
home: &Path,
|
||||
opencode_install_dir: Option<std::ffi::OsString>,
|
||||
@@ -515,6 +516,7 @@ fn opencode_extra_search_paths(
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut paths, home.join("bin"));
|
||||
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
|
||||
push_unique_path(&mut paths, home.join(".bun").join("bin"));
|
||||
push_unique_path(&mut paths, home.join("go").join("bin"));
|
||||
}
|
||||
|
||||
@@ -718,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())
|
||||
@@ -734,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)
|
||||
}
|
||||
@@ -789,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!(
|
||||
@@ -807,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(());
|
||||
}
|
||||
|
||||
@@ -849,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();
|
||||
@@ -858,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}"))?;
|
||||
@@ -1005,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;
|
||||
|
||||
@@ -1027,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}"))?;
|
||||
@@ -1051,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()
|
||||
};
|
||||
|
||||
@@ -1116,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}"))?;
|
||||
@@ -1162,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> {
|
||||
@@ -1280,6 +1384,7 @@ mod tests {
|
||||
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
|
||||
@@ -1290,7 +1395,7 @@ mod tests {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let same_dir = Some(std::ffi::OsString::from("/same/path"));
|
||||
|
||||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);
|
||||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None);
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
@@ -1299,6 +1404,18 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_extra_search_paths_deduplicates_bun_default_dir() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let paths = opencode_extra_search_paths(&home, None, None, None);
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
|
||||
@@ -1323,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,6 +22,7 @@ pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
@@ -47,6 +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::*;
|
||||
|
||||
@@ -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]
|
||||
@@ -159,7 +163,7 @@ pub async fn queryProviderUsage(
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {}", e))?;
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
|
||||
let provider = providers.get(&providerId);
|
||||
let is_copilot = provider
|
||||
@@ -182,11 +186,11 @@ pub async fn queryProviderUsage(
|
||||
Some(account_id) => auth_manager
|
||||
.fetch_usage_for_account(account_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?,
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
||||
None => auth_manager
|
||||
.fetch_usage()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?,
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
||||
};
|
||||
let premium = &usage.quota_snapshots.premium_interactions;
|
||||
let used = premium.entitlement - premium.remaining;
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -26,8 +26,22 @@ pub async fn stream_check_provider(
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
provider,
|
||||
&config,
|
||||
&copilot_state,
|
||||
auth_override.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let result = StreamCheckService::check_with_retry(
|
||||
&app_type,
|
||||
provider,
|
||||
&config,
|
||||
auth_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 记录日志
|
||||
let _ =
|
||||
@@ -73,19 +87,40 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
&provider,
|
||||
&config,
|
||||
&copilot_state,
|
||||
auth_override.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
None
|
||||
});
|
||||
let result = StreamCheckService::check_with_retry(
|
||||
&app_type,
|
||||
&provider,
|
||||
&config,
|
||||
auth_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
|
||||
let _ = state
|
||||
.db
|
||||
@@ -154,3 +189,51 @@ async fn resolve_copilot_auth_override(
|
||||
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn resolve_claude_api_format_override(
|
||||
app_type: &AppType,
|
||||
provider: &crate::provider::Provider,
|
||||
config: &StreamCheckConfig,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
auth_override: Option<&crate::proxy::providers::AuthInfo>,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
if *app_type != AppType::Claude {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let is_copilot = auth_override
|
||||
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
|
||||
.unwrap_or(false);
|
||||
if !is_copilot {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||
|
||||
let vendor_result = match account_id.as_deref() {
|
||||
Some(id) => {
|
||||
auth_manager
|
||||
.get_model_vendor_for_account(id, &model_id)
|
||||
.await
|
||||
}
|
||||
None => auth_manager.get_model_vendor(&model_id).await,
|
||||
};
|
||||
|
||||
let api_format = match vendor_result {
|
||||
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
|
||||
Ok(Some(_)) | Ok(None) => "openai_chat",
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
|
||||
);
|
||||
"openai_chat"
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(api_format.to_string()))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ mod error;
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod lightweight;
|
||||
mod mcp;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
@@ -204,6 +205,12 @@ pub fn run() {
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
}
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||
let mut found_deeplink = false;
|
||||
for arg in &args {
|
||||
@@ -615,6 +622,12 @@ pub fn run() {
|
||||
let urls = event.urls();
|
||||
log::info!("Received {} URL(s)", urls.len());
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
@@ -1008,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
|
||||
@@ -1085,6 +1099,10 @@ pub fn run() {
|
||||
commands::delete_daily_memory_file,
|
||||
commands::search_daily_memory_files,
|
||||
commands::open_workspace_directory,
|
||||
// lightweight mode (for testing or low-resource environments)
|
||||
commands::enter_lightweight_mode,
|
||||
commands::exit_lightweight_mode,
|
||||
commands::is_lightweight_mode,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
@@ -1135,6 +1153,10 @@ pub fn run() {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
tray::apply_tray_policy(app_handle, true);
|
||||
} else if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||
@@ -1144,6 +1166,13 @@ pub fn run() {
|
||||
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||
|
||||
if url_str.starts_with("ccswitch://") {
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
|
||||
{
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
||||
match crate::deeplink::parse_deeplink_url(&url_str) {
|
||||
Ok(request) => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_skip_taskbar(true);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, false);
|
||||
}
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window
|
||||
.destroy()
|
||||
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
|
||||
}
|
||||
// else: already in lightweight mode or window not found, just set the flag
|
||||
|
||||
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("进入轻量模式");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::WebviewWindowBuilder;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, true);
|
||||
}
|
||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("退出轻量模式");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let window_config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.label == "main")
|
||||
.ok_or("主窗口配置未找到")?;
|
||||
|
||||
WebviewWindowBuilder::from_config(app, window_config)
|
||||
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
|
||||
.visible(true)
|
||||
.build()
|
||||
.map_err(|e| format!("创建主窗口失败: {e}"))?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::tray::apply_tray_policy(app, true);
|
||||
}
|
||||
|
||||
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
|
||||
crate::tray::refresh_tray_menu(app);
|
||||
log::info!("退出轻量模式");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_lightweight_mode() -> bool {
|
||||
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use crate::error::AppError;
|
||||
use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir};
|
||||
use chrono::Local;
|
||||
use indexmap::IndexMap;
|
||||
use json_five::parser::{FormatConfiguration, TrailingComma};
|
||||
use json_five::rt::parser::{
|
||||
from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair,
|
||||
JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue,
|
||||
@@ -38,9 +37,7 @@ pub fn get_openclaw_dir() -> PathBuf {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".openclaw"))
|
||||
.unwrap_or_else(|| PathBuf::from(".openclaw"))
|
||||
crate::config::get_home_dir().join(".openclaw")
|
||||
}
|
||||
|
||||
/// 获取 OpenClaw 配置文件路径
|
||||
@@ -490,11 +487,11 @@ fn derive_entry_separator(leading_ws: &str) -> String {
|
||||
}
|
||||
|
||||
fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result<RtJSONValue, AppError> {
|
||||
let source = json_five::to_string_formatted(
|
||||
value,
|
||||
FormatConfiguration::with_indent(2, TrailingComma::NONE),
|
||||
)
|
||||
.map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?;
|
||||
// `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays.
|
||||
// Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and
|
||||
// can still be parsed back into the round-trip AST we use for insertion.
|
||||
let source = serde_json::to_string_pretty(value)
|
||||
.map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?;
|
||||
|
||||
let adjusted = reindent_json5_block(&source, parent_indent);
|
||||
let text = rt_from_str(&adjusted).map_err(|e| {
|
||||
@@ -1051,4 +1048,37 @@ mod tests {
|
||||
assert!(err.to_string().contains("OpenClaw config changed on disk"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_provider_writes_empty_providers_without_panic() {
|
||||
let source = r#"{
|
||||
models: {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
'1-copy': {
|
||||
api: 'anthropic-messages',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
"#;
|
||||
|
||||
with_test_paths(source, |_| {
|
||||
let outcome = remove_provider("1-copy").unwrap();
|
||||
assert!(outcome.backup_path.is_some());
|
||||
|
||||
let config = read_openclaw_config().unwrap();
|
||||
let providers = config
|
||||
.get("models")
|
||||
.and_then(|models| models.get("providers"))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(providers.is_empty());
|
||||
|
||||
let written = fs::read_to_string(get_openclaw_config_path()).unwrap();
|
||||
assert!(written.contains("\"providers\": {}"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,40 @@ use indexmap::IndexMap;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
|
||||
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
|
||||
|
||||
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
|
||||
plugin_name == prefix
|
||||
|| plugin_name
|
||||
.strip_prefix(prefix)
|
||||
.map(|suffix| suffix.starts_with('@'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
|
||||
prefixes
|
||||
.iter()
|
||||
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
|
||||
}
|
||||
|
||||
fn canonicalize_plugin_name(plugin_name: &str) -> String {
|
||||
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
|
||||
if suffix.is_empty() || suffix.starts_with('@') {
|
||||
return format!("oh-my-openagent{suffix}");
|
||||
}
|
||||
}
|
||||
plugin_name.to_string()
|
||||
}
|
||||
|
||||
pub fn get_opencode_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_opencode_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config").join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
crate::config::get_home_dir()
|
||||
.join(".config")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
pub fn get_opencode_config_path() -> PathBuf {
|
||||
@@ -140,58 +166,56 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
|
||||
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
|
||||
|
||||
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
|
||||
|
||||
match plugins {
|
||||
Some(arr) => {
|
||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||
if plugin_name.starts_with("oh-my-opencode")
|
||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||
{
|
||||
// Adding standard OMO -> remove all Slim variants
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
{
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
|
||||
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
|
||||
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||
let already_exists = arr
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
|
||||
if !already_exists {
|
||||
arr.push(Value::String(plugin_name.to_string()));
|
||||
arr.push(Value::String(normalized_plugin_name));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
config["plugin"] = json!([plugin_name]);
|
||||
config["plugin"] = json!([normalized_plugin_name]);
|
||||
}
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
|
||||
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
if !s.starts_with(prefix) {
|
||||
return true; // Keep: doesn't match prefix at all
|
||||
}
|
||||
let rest = &s[prefix.len()..];
|
||||
rest.starts_with('-')
|
||||
})
|
||||
.map(|s| !matches_any_plugin_prefix(s, prefixes))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
|
||||
@@ -275,12 +275,18 @@ pub struct ProviderMeta {
|
||||
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY")
|
||||
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_field: Option<String>,
|
||||
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// 累加模式应用中,该 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")]
|
||||
|
||||
@@ -68,7 +68,6 @@ pub enum ProxyError {
|
||||
StreamIdleTimeout(u64),
|
||||
|
||||
/// 认证错误
|
||||
#[allow(dead_code)]
|
||||
#[error("认证失败: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+524
-186
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 负责将请求转发到上游Provider,支持故障转移
|
||||
|
||||
use super::hyper_client::ProxyResponse;
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
@@ -19,68 +20,16 @@ use super::{
|
||||
use crate::commands::CopilotAuthState;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::Response;
|
||||
use http::Extensions;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
///
|
||||
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
|
||||
/// 参考成功透传的请求,保留更多原始 header
|
||||
///
|
||||
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
|
||||
const HEADER_BLACKLIST: &[&str] = &[
|
||||
// 认证类(会被覆盖)
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
// 连接类(由 HTTP 客户端管理)
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
// 编码类(会被覆盖为 identity)
|
||||
"accept-encoding",
|
||||
// 代理转发类(保留 x-forwarded-for 和 x-real-ip)
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
// CDN/云服务商特定头
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"cf-ray",
|
||||
"cf-visitor",
|
||||
"true-client-ip",
|
||||
"fastly-client-ip",
|
||||
"x-azure-clientip",
|
||||
"x-azure-fdid",
|
||||
"x-azure-ref",
|
||||
"akamai-origin-hop",
|
||||
"x-akamai-config-log-detail",
|
||||
// 请求追踪类
|
||||
"x-request-id",
|
||||
"x-correlation-id",
|
||||
"x-trace-id",
|
||||
"x-amzn-trace-id",
|
||||
"x-b3-traceid",
|
||||
"x-b3-spanid",
|
||||
"x-b3-parentspanid",
|
||||
"x-b3-sampled",
|
||||
"traceparent",
|
||||
"tracestate",
|
||||
// anthropic 特定头单独处理,避免重复
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
// 客户端 IP 单独处理(默认透传)
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
];
|
||||
|
||||
pub struct ForwardResult {
|
||||
pub response: Response,
|
||||
pub response: ProxyResponse,
|
||||
pub provider: Provider,
|
||||
pub claude_api_format: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ForwardError {
|
||||
@@ -149,6 +98,7 @@ impl RequestForwarder {
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
extensions: Extensions,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
// 获取适配器
|
||||
@@ -225,11 +175,12 @@ impl RequestForwarder {
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
Ok((response, claude_api_format)) => {
|
||||
// 成功:记录成功并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
@@ -283,6 +234,7 @@ impl RequestForwarder {
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -353,11 +305,12 @@ impl RequestForwarder {
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
Ok((response, claude_api_format)) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
@@ -416,6 +369,7 @@ impl RequestForwarder {
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
@@ -550,11 +504,12 @@ impl RequestForwarder {
|
||||
endpoint,
|
||||
&provider_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
Ok((response, claude_api_format)) => {
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
let _ = self
|
||||
.router
|
||||
@@ -606,6 +561,7 @@ impl RequestForwarder {
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
@@ -787,13 +743,24 @@ impl RequestForwarder {
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
extensions: &Extensions,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<Response, ProxyError> {
|
||||
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
@@ -803,37 +770,53 @@ impl RequestForwarder {
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
let effective_endpoint =
|
||||
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
||||
if is_copilot {
|
||||
// GitHub Copilot uses /chat/completions without /v1 prefix
|
||||
"/chat/completions"
|
||||
} else {
|
||||
// 根据 api_format 选择目标端点
|
||||
let api_format = super::providers::get_claude_api_format(provider);
|
||||
if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
}
|
||||
}
|
||||
let resolved_claude_api_format = if adapter.name() == "Claude" {
|
||||
Some(
|
||||
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let needs_transform = match resolved_claude_api_format.as_deref() {
|
||||
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
|
||||
None => adapter.needs_transform(provider),
|
||||
};
|
||||
let (effective_endpoint, passthrough_query) =
|
||||
if needs_transform && adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
|
||||
} else {
|
||||
endpoint
|
||||
(
|
||||
endpoint.to_string(),
|
||||
split_endpoint_and_query(endpoint)
|
||||
.1
|
||||
.map(ToString::to_string),
|
||||
)
|
||||
};
|
||||
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
let url = if is_full_url {
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
} else {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
if adapter.name() == "Claude" {
|
||||
let api_format = resolved_claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||
super::providers::transform_claude_request_for_api_format(
|
||||
mapped_body,
|
||||
provider,
|
||||
api_format,
|
||||
)?
|
||||
} else {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
}
|
||||
} else {
|
||||
mapped_body
|
||||
};
|
||||
@@ -841,87 +824,11 @@ impl RequestForwarder {
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
let force_identity_encoding = needs_transform
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let client = super::http_client::get_for_provider(proxy_config);
|
||||
let mut request = client.post(&url);
|
||||
|
||||
// 只有当 timeout > 0 时才设置请求超时
|
||||
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str();
|
||||
if HEADER_BLACKLIST
|
||||
.iter()
|
||||
.any(|h| key_str.eq_ignore_ascii_case(h))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Copilot 请求:过滤会由 add_auth_headers 注入的固定指纹头,
|
||||
// 防止客户端原始头与注入头重复(reqwest header() 是追加语义)
|
||||
if is_copilot
|
||||
&& (key_str.eq_ignore_ascii_case("user-agent")
|
||||
|| key_str.eq_ignore_ascii_case("editor-version")
|
||||
|| key_str.eq_ignore_ascii_case("editor-plugin-version")
|
||||
|| key_str.eq_ignore_ascii_case("copilot-integration-id")
|
||||
|| key_str.eq_ignore_ascii_case("x-github-api-version")
|
||||
|| key_str.eq_ignore_ascii_case("openai-intent"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
// 处理 anthropic-beta Header(仅 Claude)
|
||||
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
|
||||
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
|
||||
if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
// 检查是否已包含 claude-code-20250219
|
||||
if beta_str.contains(CLAUDE_CODE_BETA) {
|
||||
beta_str.to_string()
|
||||
} else {
|
||||
// 补充 claude-code-20250219
|
||||
format!("{CLAUDE_CODE_BETA},{beta_str}")
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
}
|
||||
} else {
|
||||
// 如果客户端没有发送,使用默认值
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
};
|
||||
request = request.header("anthropic-beta", &beta_value);
|
||||
}
|
||||
|
||||
// 客户端 IP 透传(默认开启)
|
||||
if let Some(xff) = headers.get("x-forwarded-for") {
|
||||
if let Ok(xff_str) = xff.to_str() {
|
||||
request = request.header("x-forwarded-for", xff_str);
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(real_ip_str) = real_ip.to_str() {
|
||||
request = request.header("x-real-ip", real_ip_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 流式请求保守禁用压缩,避免上游压缩 SSE 在连接中断时触发解压错误。
|
||||
// 非流式请求不显式设置 Accept-Encoding,让 reqwest 自动协商压缩并透明解压。
|
||||
if should_force_identity_encoding(effective_endpoint, &filtered_body, headers) {
|
||||
request = request.header("accept-encoding", "identity");
|
||||
}
|
||||
|
||||
// 使用适配器添加认证头
|
||||
if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
|
||||
if auth.strategy == AuthStrategy::GitHubCopilot {
|
||||
if let Some(app_handle) = &self.app_handle {
|
||||
@@ -972,17 +879,211 @@ impl RequestForwarder {
|
||||
));
|
||||
}
|
||||
}
|
||||
request = adapter.add_auth_headers(request, &auth);
|
||||
adapter.get_auth_headers(&auth)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
|
||||
let copilot_fingerprint_headers: &[&str] = if is_copilot {
|
||||
&[
|
||||
"user-agent",
|
||||
"editor-version",
|
||||
"editor-plugin-version",
|
||||
"copilot-integration-id",
|
||||
"x-github-api-version",
|
||||
"openai-intent",
|
||||
]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
|
||||
// 预计算上游 host 值(用于在原位替换 host header)
|
||||
let upstream_host = url
|
||||
.parse::<http::Uri>()
|
||||
.ok()
|
||||
.and_then(|u| u.authority().map(|a| a.to_string()));
|
||||
|
||||
// 预计算 anthropic-beta 值(仅 Claude)
|
||||
let anthropic_beta_value = if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
Some(if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
if beta_str.contains(CLAUDE_CODE_BETA) {
|
||||
beta_str.to_string()
|
||||
} else {
|
||||
format!("{CLAUDE_CODE_BETA},{beta_str}")
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 构建有序 HeaderMap — 内联替换,保持客户端原始顺序
|
||||
// ============================================================
|
||||
let mut ordered_headers = http::HeaderMap::new();
|
||||
let mut saw_auth = false;
|
||||
let mut saw_accept_encoding = false;
|
||||
let mut saw_anthropic_beta = false;
|
||||
let mut saw_anthropic_version = false;
|
||||
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str();
|
||||
|
||||
// --- host — 原位替换为上游 host(保持客户端原始位置) ---
|
||||
if key_str.eq_ignore_ascii_case("host") {
|
||||
if let Some(ref host_val) = upstream_host {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(host_val) {
|
||||
ordered_headers.append(key.clone(), hv);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- 连接 / 追踪 / CDN 类 — 无条件跳过 ---
|
||||
if matches!(
|
||||
key_str,
|
||||
"content-length"
|
||||
| "transfer-encoding"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-port"
|
||||
| "x-forwarded-proto"
|
||||
| "forwarded"
|
||||
| "cf-connecting-ip"
|
||||
| "cf-ipcountry"
|
||||
| "cf-ray"
|
||||
| "cf-visitor"
|
||||
| "true-client-ip"
|
||||
| "fastly-client-ip"
|
||||
| "x-azure-clientip"
|
||||
| "x-azure-fdid"
|
||||
| "x-azure-ref"
|
||||
| "akamai-origin-hop"
|
||||
| "x-akamai-config-log-detail"
|
||||
| "x-request-id"
|
||||
| "x-correlation-id"
|
||||
| "x-trace-id"
|
||||
| "x-amzn-trace-id"
|
||||
| "x-b3-traceid"
|
||||
| "x-b3-spanid"
|
||||
| "x-b3-parentspanid"
|
||||
| "x-b3-sampled"
|
||||
| "traceparent"
|
||||
| "tracestate"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) ---
|
||||
if key_str.eq_ignore_ascii_case("authorization")
|
||||
|| key_str.eq_ignore_ascii_case("x-api-key")
|
||||
|| key_str.eq_ignore_ascii_case("x-goog-api-key")
|
||||
{
|
||||
if !saw_auth {
|
||||
saw_auth = true;
|
||||
for (ah_name, ah_value) in &auth_headers {
|
||||
ordered_headers.append(ah_name.clone(), ah_value.clone());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 ---
|
||||
if key_str.eq_ignore_ascii_case("accept-encoding") {
|
||||
if !saw_accept_encoding {
|
||||
saw_accept_encoding = true;
|
||||
if force_identity_encoding {
|
||||
ordered_headers.append(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
http::HeaderValue::from_static("identity"),
|
||||
);
|
||||
} else {
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
|
||||
if key_str.eq_ignore_ascii_case("anthropic-beta") {
|
||||
if !saw_anthropic_beta {
|
||||
saw_anthropic_beta = true;
|
||||
if let Some(ref beta_val) = anthropic_beta_value {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
|
||||
ordered_headers.append("anthropic-beta", hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- anthropic-version — 透传客户端值 ---
|
||||
if key_str.eq_ignore_ascii_case("anthropic-version") {
|
||||
saw_anthropic_version = true;
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Copilot 指纹头 — 跳过(由 auth_headers 提供) ---
|
||||
if copilot_fingerprint_headers
|
||||
.iter()
|
||||
.any(|h| key_str.eq_ignore_ascii_case(h))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- 默认:透传 ---
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
|
||||
// 注意:只设置一次,避免重复
|
||||
if adapter.name() == "Claude" {
|
||||
let version_str = headers
|
||||
.get("anthropic-version")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("2023-06-01");
|
||||
request = request.header("anthropic-version", version_str);
|
||||
// 如果原始请求中没有认证头,在末尾追加
|
||||
if !saw_auth && !auth_headers.is_empty() {
|
||||
for (ah_name, ah_value) in &auth_headers {
|
||||
ordered_headers.append(ah_name.clone(), ah_value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// transform / SSE 路径在缺失时补 identity;普通透传不主动补 accept-encoding
|
||||
if !saw_accept_encoding && force_identity_encoding {
|
||||
ordered_headers.append(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
http::HeaderValue::from_static("identity"),
|
||||
);
|
||||
}
|
||||
|
||||
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
|
||||
if !saw_anthropic_beta {
|
||||
if let Some(ref beta_val) = anthropic_beta_value {
|
||||
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
|
||||
ordered_headers.append("anthropic-beta", hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// anthropic-version:仅在缺失时补充默认值
|
||||
if adapter.name() == "Claude" && !saw_anthropic_version {
|
||||
ordered_headers.append(
|
||||
"anthropic-version",
|
||||
http::HeaderValue::from_static("2023-06-01"),
|
||||
);
|
||||
}
|
||||
|
||||
// 序列化请求体
|
||||
let body_bytes = serde_json::to_vec(&filtered_body)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?;
|
||||
|
||||
// 确保 content-type 存在
|
||||
if !ordered_headers.contains_key(http::header::CONTENT_TYPE) {
|
||||
ordered_headers.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
}
|
||||
|
||||
// 输出请求信息日志
|
||||
@@ -1000,25 +1101,75 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// 确定超时
|
||||
let timeout = if self.non_streaming_timeout.is_zero() {
|
||||
std::time::Duration::from_secs(600) // 默认 600 秒
|
||||
} else {
|
||||
self.non_streaming_timeout
|
||||
};
|
||||
|
||||
// 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
|
||||
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
|
||||
let upstream_proxy_url: Option<String> = proxy_config
|
||||
.filter(|c| c.enabled)
|
||||
.and_then(super::http_client::build_proxy_url_from_config)
|
||||
.or_else(super::http_client::get_current_proxy_url);
|
||||
|
||||
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
|
||||
let is_socks_proxy = upstream_proxy_url
|
||||
.as_deref()
|
||||
.map(|u| u.starts_with("socks5"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let uri: http::Uri = url
|
||||
.parse()
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
|
||||
|
||||
// 发送请求
|
||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(e.to_string())
|
||||
let response = if is_socks_proxy {
|
||||
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
|
||||
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
|
||||
let client = super::http_client::get_for_provider(proxy_config);
|
||||
let mut request = client.post(&url);
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
}
|
||||
})?;
|
||||
for (key, value) in &ordered_headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
let reqwest_resp = request.body(body_bytes).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(e.to_string())
|
||||
}
|
||||
})?;
|
||||
ProxyResponse::Reqwest(reqwest_resp)
|
||||
} else {
|
||||
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
|
||||
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
|
||||
super::hyper_client::send_request(
|
||||
uri,
|
||||
http::Method::POST,
|
||||
ordered_headers,
|
||||
extensions.clone(),
|
||||
body_bytes,
|
||||
timeout,
|
||||
upstream_proxy_url.as_deref(),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
// 检查响应状态
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
Ok(response)
|
||||
Ok((response, resolved_claude_api_format))
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = response.text().await.ok();
|
||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
||||
|
||||
Err(ProxyError::UpstreamError {
|
||||
status: status_code,
|
||||
@@ -1027,6 +1178,68 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_claude_api_format(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
body: &Value,
|
||||
is_copilot: bool,
|
||||
) -> String {
|
||||
if !is_copilot {
|
||||
return super::providers::get_claude_api_format(provider).to_string();
|
||||
}
|
||||
|
||||
let model = body.get("model").and_then(|value| value.as_str());
|
||||
if let Some(model_id) = model {
|
||||
if self
|
||||
.is_copilot_openai_vendor_model(provider, model_id)
|
||||
.await
|
||||
{
|
||||
return "openai_responses".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
"openai_chat".to_string()
|
||||
}
|
||||
|
||||
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
|
||||
let Some(app_handle) = &self.app_handle else {
|
||||
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
|
||||
return false;
|
||||
};
|
||||
|
||||
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||
let copilot_auth = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("github_copilot"));
|
||||
|
||||
let vendor_result = match account_id.as_deref() {
|
||||
Some(id) => {
|
||||
copilot_auth
|
||||
.get_model_vendor_for_account(id, model_id)
|
||||
.await
|
||||
}
|
||||
None => copilot_auth.get_model_vendor(model_id).await,
|
||||
};
|
||||
|
||||
match vendor_result {
|
||||
Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"),
|
||||
Ok(None) => {
|
||||
log::debug!(
|
||||
"[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||
match error {
|
||||
// 网络和上游错误:都应该尝试下一个供应商
|
||||
@@ -1173,6 +1386,78 @@ fn extract_json_error_message(body: &Value) -> Option<String> {
|
||||
.find_map(|value| value.as_str().map(ToString::to_string))
|
||||
}
|
||||
|
||||
fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) {
|
||||
endpoint
|
||||
.split_once('?')
|
||||
.map_or((endpoint, None), |(path, query)| (path, Some(query)))
|
||||
}
|
||||
|
||||
fn strip_beta_query(query: Option<&str>) -> Option<String> {
|
||||
let filtered = query.map(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.filter(|pair| !pair.is_empty() && !pair.starts_with("beta="))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
});
|
||||
|
||||
match filtered.as_deref() {
|
||||
Some("") | None => None,
|
||||
Some(_) => filtered,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_claude_messages_path(path: &str) -> bool {
|
||||
matches!(path, "/v1/messages" | "/claude/v1/messages")
|
||||
}
|
||||
|
||||
fn rewrite_claude_transform_endpoint(
|
||||
endpoint: &str,
|
||||
api_format: &str,
|
||||
is_copilot: bool,
|
||||
) -> (String, Option<String>) {
|
||||
let (path, query) = split_endpoint_and_query(endpoint);
|
||||
let passthrough_query = if is_claude_messages_path(path) {
|
||||
strip_beta_query(query)
|
||||
} else {
|
||||
query.map(ToString::to_string)
|
||||
};
|
||||
|
||||
if !is_claude_messages_path(path) {
|
||||
return (endpoint.to_string(), passthrough_query);
|
||||
}
|
||||
|
||||
let target_path = if is_copilot && api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else if is_copilot {
|
||||
"/chat/completions"
|
||||
} else if api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/chat/completions"
|
||||
};
|
||||
|
||||
let rewritten = match passthrough_query.as_deref() {
|
||||
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
|
||||
_ => target_path.to_string(),
|
||||
};
|
||||
|
||||
(rewritten, passthrough_query)
|
||||
}
|
||||
|
||||
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(query) if !query.is_empty() => {
|
||||
if base_url.contains('?') {
|
||||
format!("{base_url}&{query}")
|
||||
} else {
|
||||
format!("{base_url}?{query}")
|
||||
}
|
||||
}
|
||||
_ => base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_force_identity_encoding(
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
@@ -1213,7 +1498,8 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{header::ACCEPT, HeaderMap, HeaderValue};
|
||||
use axum::http::header::{HeaderValue, ACCEPT};
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -1281,6 +1567,58 @@ mod tests {
|
||||
assert_eq!(summary, "line1 line2...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&foo=bar",
|
||||
"openai_chat",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_beta_for_responses() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/claude/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
|
||||
let (endpoint, passthrough_query) =
|
||||
rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true);
|
||||
|
||||
assert_eq!(endpoint, "/chat/completions?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_to_full_url_preserves_existing_query_string() {
|
||||
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
|
||||
|
||||
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_for_stream_flag_requests() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
+112
-29
@@ -18,7 +18,10 @@ use super::{
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_responses,
|
||||
},
|
||||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
|
||||
},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
usage::parser::TokenUsage,
|
||||
@@ -27,6 +30,7 @@ use super::{
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ============================================================================
|
||||
@@ -61,12 +65,28 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|path_and_query| path_and_query.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
@@ -77,9 +97,10 @@ pub async fn handle_messages(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -95,6 +116,11 @@ pub async fn handle_messages(
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let api_format = result
|
||||
.claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| get_claude_api_format(&ctx.provider))
|
||||
.to_string();
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
@@ -103,7 +129,8 @@ pub async fn handle_messages(
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
if needs_transform {
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 通用响应处理(透传模式)
|
||||
@@ -114,14 +141,14 @@ pub async fn handle_messages(
|
||||
///
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
async fn handle_claude_transform(
|
||||
response: reqwest::Response,
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
_original_body: &Value,
|
||||
is_stream: bool,
|
||||
api_format: &str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let api_format = get_claude_api_format(&ctx.provider);
|
||||
|
||||
if is_stream {
|
||||
// 根据 api_format 选择流式转换器
|
||||
@@ -199,12 +226,14 @@ async fn handle_claude_transform(
|
||||
}
|
||||
|
||||
// 非流式响应转换 (OpenAI/Responses → Anthropic)
|
||||
let response_headers = response.headers().clone();
|
||||
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
log::error!("[Claude] 读取响应体失败: {e}");
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
std::time::Duration::ZERO
|
||||
};
|
||||
let (mut response_headers, _status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
@@ -257,13 +286,10 @@ async fn handle_claude_transform(
|
||||
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
|
||||
for (key, value) in response_headers.iter() {
|
||||
if key.as_str().to_lowercase() != "content-length"
|
||||
&& key.as_str().to_lowercase() != "transfer-encoding"
|
||||
{
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
|
||||
builder = builder.header("content-type", "application/json");
|
||||
@@ -280,6 +306,13 @@ async fn handle_claude_transform(
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
|
||||
match uri.query() {
|
||||
Some(query) => format!("{endpoint}?{query}"),
|
||||
None => endpoint.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex API 处理器
|
||||
// ============================================================================
|
||||
@@ -287,11 +320,23 @@ async fn handle_claude_transform(
|
||||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||||
pub async fn handle_chat_completions(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/chat/completions");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -302,9 +347,10 @@ pub async fn handle_chat_completions(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/chat/completions",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -328,11 +374,23 @@ pub async fn handle_chat_completions(
|
||||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -343,9 +401,10 @@ pub async fn handle_responses(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -369,11 +428,23 @@ pub async fn handle_responses(
|
||||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -384,9 +455,10 @@ pub async fn handle_responses_compact(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses/compact",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -415,9 +487,19 @@ pub async fn handle_responses_compact(
|
||||
pub async fn handle_gemini(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
@@ -441,6 +523,7 @@ pub async fn handle_gemini(
|
||||
endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -219,7 +219,12 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60));
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
// 禁用 reqwest 自动解压:防止 reqwest 覆盖客户端原始 accept-encoding header。
|
||||
// 响应解压由 response_processor 根据 content-encoding 手动处理。
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate();
|
||||
|
||||
// 有代理地址则使用代理,否则跟随系统代理
|
||||
if let Some(url) = proxy_url {
|
||||
@@ -332,7 +337,7 @@ pub fn mask_url(url: &str) -> String {
|
||||
/// 根据供应商单独代理配置构建代理 URL
|
||||
///
|
||||
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||
let host = config.proxy_host.as_deref()?;
|
||||
let port = config.proxy_port?;
|
||||
@@ -387,6 +392,9 @@ pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) ->
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Hyper-based HTTP client for proxy forwarding
|
||||
//!
|
||||
//! Uses raw TCP/TLS writes to preserve exact original header name casing.
|
||||
//! Supports HTTP CONNECT tunneling through upstream proxies.
|
||||
//! Falls back to hyper-util Client (title-case headers) when raw write is not feasible.
|
||||
|
||||
use super::ProxyError;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::Stream;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Our own header case map: maps lowercase header name → original wire-casing bytes.
|
||||
///
|
||||
/// This is a backup mechanism independent of hyper's internal `HeaderCaseMap` (which is
|
||||
/// `pub(crate)` and cannot be directly inspected or constructed from outside hyper).
|
||||
///
|
||||
/// Populated in `server.rs` by peeking at raw TCP bytes before hyper parses them.
|
||||
/// Used in `send_request` to manually write headers with original casing when hyper's
|
||||
/// own mechanism fails.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct OriginalHeaderCases {
|
||||
/// Ordered list of (lowercase_name, original_wire_bytes) pairs.
|
||||
/// Multiple entries with the same name are allowed (for repeated headers).
|
||||
pub cases: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl OriginalHeaderCases {
|
||||
/// Parse raw HTTP request bytes (from TcpStream::peek) to extract original header casings.
|
||||
pub fn from_raw_bytes(buf: &[u8]) -> Self {
|
||||
let mut headers_buf = [httparse::EMPTY_HEADER; 128];
|
||||
let mut req = httparse::Request::new(&mut headers_buf);
|
||||
// We don't care if parsing is partial — we just want the header names we can get
|
||||
let _ = req.parse(buf);
|
||||
|
||||
let mut cases = Vec::new();
|
||||
for header in req.headers.iter() {
|
||||
if header.name.is_empty() {
|
||||
break;
|
||||
}
|
||||
cases.push((
|
||||
header.name.to_ascii_lowercase(),
|
||||
header.name.as_bytes().to_vec(),
|
||||
));
|
||||
}
|
||||
|
||||
Self { cases }
|
||||
}
|
||||
}
|
||||
|
||||
type HyperClient = Client<
|
||||
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
|
||||
http_body_util::Full<Bytes>,
|
||||
>;
|
||||
|
||||
/// Lazily-initialized hyper client with header-case preservation enabled.
|
||||
fn global_hyper_client() -> &'static HyperClient {
|
||||
static CLIENT: OnceLock<HyperClient> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
let connector = HttpsConnectorBuilder::new()
|
||||
.with_webpki_roots()
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
|
||||
Client::builder(TokioExecutor::new())
|
||||
.http1_preserve_header_case(true)
|
||||
.http1_title_case_headers(true)
|
||||
.build(connector)
|
||||
})
|
||||
}
|
||||
|
||||
/// Unified response wrapper that can hold either a hyper or reqwest response.
|
||||
///
|
||||
/// The hyper variant is used for the main (direct) path with header-case preservation.
|
||||
/// The reqwest variant is the fallback when an upstream HTTP/SOCKS5 proxy is configured.
|
||||
pub enum ProxyResponse {
|
||||
Hyper(hyper::Response<hyper::body::Incoming>),
|
||||
Reqwest(reqwest::Response),
|
||||
}
|
||||
|
||||
impl ProxyResponse {
|
||||
pub fn status(&self) -> http::StatusCode {
|
||||
match self {
|
||||
Self::Hyper(r) => r.status(),
|
||||
Self::Reqwest(r) => r.status(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn headers(&self) -> &http::HeaderMap {
|
||||
match self {
|
||||
Self::Hyper(r) => r.headers(),
|
||||
Self::Reqwest(r) => r.headers(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortcut: extract `content-type` header value as `&str`.
|
||||
pub fn content_type(&self) -> Option<&str> {
|
||||
self.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// Check if the response is an SSE stream.
|
||||
pub fn is_sse(&self) -> bool {
|
||||
self.content_type()
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Consume the response and collect the full body into `Bytes`.
|
||||
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
|
||||
match self {
|
||||
Self::Hyper(r) => {
|
||||
let collected = r.into_body().collect().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
Ok(collected.to_bytes())
|
||||
}
|
||||
Self::Reqwest(r) => r.bytes().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the response and return a byte-chunk stream (for SSE pass-through).
|
||||
pub fn bytes_stream(self) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
use futures::StreamExt;
|
||||
|
||||
match self {
|
||||
Self::Hyper(r) => {
|
||||
let body = r.into_body();
|
||||
let stream = futures::stream::unfold(body, |mut body| async {
|
||||
match body.frame().await {
|
||||
Some(Ok(frame)) => {
|
||||
if let Ok(data) = frame.into_data() {
|
||||
if data.is_empty() {
|
||||
Some((Ok(Bytes::new()), body))
|
||||
} else {
|
||||
Some((Ok(data), body))
|
||||
}
|
||||
} else {
|
||||
Some((Ok(Bytes::new()), body))
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => Some((Err(std::io::Error::other(e.to_string())), body)),
|
||||
None => None,
|
||||
}
|
||||
})
|
||||
.filter(|result| {
|
||||
futures::future::ready(!matches!(result, Ok(ref b) if b.is_empty()))
|
||||
});
|
||||
Box::pin(stream)
|
||||
as std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>
|
||||
}
|
||||
Self::Reqwest(r) => {
|
||||
let stream = r
|
||||
.bytes_stream()
|
||||
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string())));
|
||||
Box::pin(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an HTTP request with header-case preservation.
|
||||
///
|
||||
/// Uses a two-tier strategy:
|
||||
/// 1. Primary: raw HTTP/1.1 write via TLS stream with exact original header casing
|
||||
/// (from `OriginalHeaderCases` captured by peek in server.rs), then hand off to
|
||||
/// hyper for response parsing.
|
||||
/// 2. Fallback: hyper-util Client with `title_case_headers(true)` when raw write
|
||||
/// isn't feasible (e.g., missing original cases).
|
||||
///
|
||||
/// The caller is expected to include `Host` in the supplied `headers` at the
|
||||
/// correct position.
|
||||
///
|
||||
/// `proxy_url`: optional upstream HTTP proxy URL (e.g. `http://127.0.0.1:7890`).
|
||||
/// When set, the raw write path uses HTTP CONNECT tunneling through the proxy,
|
||||
/// so header-case preservation works even when an upstream proxy is configured.
|
||||
pub async fn send_request(
|
||||
uri: http::Uri,
|
||||
method: http::Method,
|
||||
headers: http::HeaderMap,
|
||||
original_extensions: http::Extensions,
|
||||
body: Vec<u8>,
|
||||
timeout: std::time::Duration,
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
// Extract our own OriginalHeaderCases if available
|
||||
let original_cases = original_extensions.get::<OriginalHeaderCases>().cloned();
|
||||
let has_cases = original_cases
|
||||
.as_ref()
|
||||
.map(|c| !c.cases.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::debug!(
|
||||
"[HyperClient] Sending request: uri={uri}, header_count={}, \
|
||||
has_host={}, has_original_cases={has_cases}, proxy={:?}",
|
||||
headers.len(),
|
||||
headers.contains_key(http::header::HOST),
|
||||
proxy_url,
|
||||
);
|
||||
|
||||
if has_cases {
|
||||
// Primary path: use raw write + hyper handshake for exact header casing
|
||||
let result = tokio::time::timeout(
|
||||
timeout,
|
||||
send_raw_request(
|
||||
&uri,
|
||||
&method,
|
||||
&headers,
|
||||
original_cases.as_ref().unwrap(),
|
||||
&body,
|
||||
proxy_url,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?;
|
||||
|
||||
match result {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if proxy_url.is_some() {
|
||||
// Don't bypass configured proxy with direct connect fallback
|
||||
return Err(e);
|
||||
}
|
||||
log::warn!("[HyperClient] Raw write failed, falling back to hyper-util: {e}");
|
||||
// Fall through to hyper-util Client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hyper-util Client (title-case headers, no proxy support)
|
||||
let mut req = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(&uri)
|
||||
.body(http_body_util::Full::new(Bytes::from(body)))
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Failed to build request: {e}")))?;
|
||||
|
||||
*req.headers_mut() = headers;
|
||||
*req.extensions_mut() = original_extensions;
|
||||
|
||||
let client = global_hyper_client();
|
||||
let resp = tokio::time::timeout(timeout, client.request(req))
|
||||
.await
|
||||
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("上游请求失败: {e}")))?;
|
||||
|
||||
Ok(ProxyResponse::Hyper(resp))
|
||||
}
|
||||
|
||||
/// TCP or TLS stream returned by `connect_via_proxy`.
|
||||
///
|
||||
/// When the proxy URL uses `https://`, the connection to the proxy itself is
|
||||
/// TLS-wrapped before sending the CONNECT request. The enum lets
|
||||
/// `send_raw_request` work with either variant generically.
|
||||
enum ProxyStream {
|
||||
Tcp(tokio::net::TcpStream),
|
||||
Tls(Box<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>),
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for ProxyStream {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_read(cx, buf),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for ProxyStream {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_write(cx, buf),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_flush(cx),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_shutdown(cx),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send request via raw TCP/TLS with exact original header casing.
|
||||
///
|
||||
/// When `proxy_url` is provided, establishes an HTTP CONNECT tunnel through
|
||||
/// the proxy first, then performs TLS + raw write through the tunnel.
|
||||
/// This preserves header casing even when an upstream proxy is configured.
|
||||
async fn send_raw_request(
|
||||
uri: &http::Uri,
|
||||
method: &http::Method,
|
||||
headers: &http::HeaderMap,
|
||||
original_cases: &OriginalHeaderCases,
|
||||
body: &[u8],
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let scheme = uri.scheme_str().unwrap_or("https");
|
||||
let host = uri
|
||||
.host()
|
||||
.ok_or_else(|| ProxyError::ForwardFailed("URI has no host".into()))?;
|
||||
let port = uri
|
||||
.port_u16()
|
||||
.unwrap_or(if scheme == "https" { 443 } else { 80 });
|
||||
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
|
||||
// Build raw HTTP request bytes
|
||||
let raw = build_raw_request(method, path_and_query, headers, original_cases, body);
|
||||
|
||||
// Establish TCP connection — either direct or through HTTP CONNECT proxy
|
||||
let stream = if let Some(proxy) = proxy_url {
|
||||
connect_via_proxy(proxy, host, port).await?
|
||||
} else {
|
||||
ProxyStream::Tcp(
|
||||
tokio::net::TcpStream::connect((host, port))
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("TCP connect failed: {e}")))?,
|
||||
)
|
||||
};
|
||||
|
||||
if scheme == "https" {
|
||||
let tls_connector = global_tls_connector();
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid server name: {e}")))?;
|
||||
let mut tls_stream = tls_connector
|
||||
.connect(server_name, stream)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("TLS handshake failed: {e}")))?;
|
||||
|
||||
tls_stream
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(tls_stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
} else {
|
||||
let mut stream = stream;
|
||||
stream
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a connection through an HTTP CONNECT proxy tunnel.
|
||||
///
|
||||
/// 1. Connect TCP to the proxy server (TLS-wrapped when `https://` proxy)
|
||||
/// 2. Send `CONNECT host:port HTTP/1.1` with optional `Proxy-Authorization`
|
||||
/// 3. Read the proxy's 200 response (407 → `AuthError`)
|
||||
/// 4. Return the tunneled stream (ready for target TLS handshake + raw write)
|
||||
async fn connect_via_proxy(
|
||||
proxy_url: &str,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> Result<ProxyStream, ProxyError> {
|
||||
use base64::Engine;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
let parsed = url::Url::parse(proxy_url)
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy URL: {e}")))?;
|
||||
|
||||
let proxy_host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| ProxyError::ForwardFailed("Proxy URL has no host".into()))?;
|
||||
let proxy_port = parsed
|
||||
.port()
|
||||
.unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
|
||||
|
||||
// Build Proxy-Authorization header if credentials are present
|
||||
let proxy_auth = if !parsed.username().is_empty() {
|
||||
let password = parsed.password().unwrap_or("");
|
||||
let credentials = format!("{}:{}", parsed.username(), password);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
|
||||
Some(format!("Proxy-Authorization: Basic {encoded}\r\n"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Connect to the proxy
|
||||
let tcp = tokio::net::TcpStream::connect((proxy_host, proxy_port))
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TCP connect failed: {e}")))?;
|
||||
|
||||
// Wrap with TLS if the proxy URL uses https://
|
||||
let mut stream: ProxyStream = if parsed.scheme() == "https" {
|
||||
let tls_connector = global_tls_connector();
|
||||
let server_name = rustls::pki_types::ServerName::try_from(proxy_host.to_string())
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy server name: {e}")))?;
|
||||
let tls_stream = tls_connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TLS handshake failed: {e}")))?;
|
||||
ProxyStream::Tls(Box::new(tls_stream))
|
||||
} else {
|
||||
ProxyStream::Tcp(tcp)
|
||||
};
|
||||
|
||||
// Send CONNECT request
|
||||
let mut connect_req = format!(
|
||||
"CONNECT {target_host}:{target_port} HTTP/1.1\r\n\
|
||||
Host: {target_host}:{target_port}\r\n"
|
||||
);
|
||||
if let Some(auth) = &proxy_auth {
|
||||
connect_req.push_str(auth);
|
||||
}
|
||||
connect_req.push_str("\r\n");
|
||||
|
||||
stream
|
||||
.write_all(connect_req.as_bytes())
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
|
||||
|
||||
// Read the proxy's response status line
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
let mut status_line = String::new();
|
||||
reader
|
||||
.read_line(&mut status_line)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT read failed: {e}")))?;
|
||||
|
||||
// Expect "HTTP/1.1 200 ..." or "HTTP/1.0 200 ..."
|
||||
if !status_line.contains(" 200 ") {
|
||||
if status_line.contains(" 407 ") {
|
||||
return Err(ProxyError::AuthError(format!(
|
||||
"Proxy authentication required (407): {}",
|
||||
status_line.trim()
|
||||
)));
|
||||
}
|
||||
return Err(ProxyError::ForwardFailed(format!(
|
||||
"Proxy CONNECT rejected: {}",
|
||||
status_line.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// Drain remaining response headers (until empty line)
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT header read: {e}")))?;
|
||||
if line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// BufReader might have buffered data; drop it to get raw stream back.
|
||||
// Since CONNECT response is headers-only (no body), and we read until \r\n\r\n,
|
||||
// the BufReader buffer should be empty at this point.
|
||||
drop(reader);
|
||||
|
||||
log::debug!(
|
||||
"[HyperClient] CONNECT tunnel established via {proxy_host}:{proxy_port} -> {target_host}:{target_port}"
|
||||
);
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Lazily-initialized TLS connector for raw connections.
|
||||
///
|
||||
/// Loads both webpki roots AND native system certificates so that
|
||||
/// proxy MITM CAs (e.g. Clash, mitmproxy) installed in the system
|
||||
/// keychain are trusted through the CONNECT tunnel.
|
||||
fn global_tls_connector() -> &'static tokio_rustls::TlsConnector {
|
||||
static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
|
||||
CONNECTOR.get_or_init(|| {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
// Baseline: Mozilla/webpki roots
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
// Native system certs (includes user-installed proxy CAs)
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
let (added, _errors) = root_store.add_parsable_certificates(native.certs);
|
||||
log::debug!("[HyperClient] TLS root store: webpki + {added} native certs");
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
tokio_rustls::TlsConnector::from(std::sync::Arc::new(config))
|
||||
})
|
||||
}
|
||||
|
||||
/// Build raw HTTP/1.1 request bytes with original header casing.
|
||||
fn build_raw_request(
|
||||
method: &http::Method,
|
||||
path_and_query: &str,
|
||||
headers: &http::HeaderMap,
|
||||
original_cases: &OriginalHeaderCases,
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut raw = Vec::with_capacity(4096 + body.len());
|
||||
|
||||
// Request line
|
||||
raw.extend_from_slice(method.as_str().as_bytes());
|
||||
raw.extend_from_slice(b" ");
|
||||
raw.extend_from_slice(path_and_query.as_bytes());
|
||||
raw.extend_from_slice(b" HTTP/1.1\r\n");
|
||||
|
||||
// Headers with original casing, emitted in original wire order.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Walk `original_cases.cases` in order — this preserves the exact
|
||||
// header sequence the client sent. For each entry, emit the stored
|
||||
// original-casing name plus the current value from `headers` (the
|
||||
// proxy may have rewritten the value, e.g. Authorization).
|
||||
// Repeated headers with the same name are handled by tracking a
|
||||
// per-name value cursor so we step through `get_all()` in order.
|
||||
// 2. After the original headers, append any headers that exist in
|
||||
// `headers` but were not present in the original request (i.e. added
|
||||
// by the proxy). These are emitted in lowercase.
|
||||
//
|
||||
// This replaces the old `for name in headers.keys()` loop which iterated
|
||||
// in hash-map order, destroying the original header sequence.
|
||||
let mut emitted: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::with_capacity(original_cases.cases.len());
|
||||
// Per-name cursor: how many values we have already emitted for each name.
|
||||
let mut value_cursor: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::with_capacity(original_cases.cases.len());
|
||||
|
||||
for (lower_name, orig_name_bytes) in &original_cases.cases {
|
||||
if let Ok(header_name) = http::header::HeaderName::from_bytes(lower_name.as_bytes()) {
|
||||
let all_values: Vec<_> = headers.get_all(&header_name).iter().collect();
|
||||
let cursor = value_cursor.entry(lower_name.clone()).or_insert(0);
|
||||
if let Some(value) = all_values.get(*cursor) {
|
||||
raw.extend_from_slice(orig_name_bytes);
|
||||
raw.extend_from_slice(b": ");
|
||||
raw.extend_from_slice(value.as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
*cursor += 1;
|
||||
emitted.insert(lower_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append proxy-added headers (not present in the original request).
|
||||
for name in headers.keys() {
|
||||
let lower = name.as_str().to_ascii_lowercase();
|
||||
if !emitted.contains(&lower) {
|
||||
for value in headers.get_all(name) {
|
||||
raw.extend_from_slice(name.as_str().as_bytes());
|
||||
raw.extend_from_slice(b": ");
|
||||
raw.extend_from_slice(value.as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
}
|
||||
emitted.insert(lower);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Content-Length if not already present
|
||||
if !headers.contains_key(http::header::CONTENT_LENGTH) {
|
||||
raw.extend_from_slice(b"Content-Length: ");
|
||||
raw.extend_from_slice(body.len().to_string().as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
}
|
||||
|
||||
// End of headers + body
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
raw.extend_from_slice(body);
|
||||
|
||||
raw
|
||||
}
|
||||
|
||||
/// Use hyper's low-level client to parse the response on a stream where we've
|
||||
/// already written the request.
|
||||
///
|
||||
/// `WriteFilter` discards any writes from hyper (it would try to send its own
|
||||
/// request encoding), while passing reads through transparently.
|
||||
async fn do_hyper_response<S>(
|
||||
stream: WriteFilter<S>,
|
||||
method: http::Method,
|
||||
) -> Result<ProxyResponse, ProxyError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let io = hyper_util::rt::TokioIo::new(stream);
|
||||
|
||||
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
|
||||
.preserve_header_case(true)
|
||||
.handshake::<_, http_body_util::Full<Bytes>>(io)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Handshake failed: {e}")))?;
|
||||
|
||||
// Spawn the connection driver (reads responses from the stream)
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
log::debug!("[HyperClient] raw conn driver error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// Send a dummy request through hyper — hyper will encode this and try to write it,
|
||||
// but WriteFilter discards all writes. Hyper will then read the response from the stream.
|
||||
let dummy_req = http::Request::builder()
|
||||
.method(method)
|
||||
.uri("/")
|
||||
.body(http_body_util::Full::new(Bytes::new()))
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Build dummy request: {e}")))?;
|
||||
|
||||
let resp = sender
|
||||
.send_request(dummy_req)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Response parse failed: {e}")))?;
|
||||
|
||||
Ok(ProxyResponse::Hyper(resp))
|
||||
}
|
||||
|
||||
/// A stream wrapper that discards all writes but passes reads through.
|
||||
///
|
||||
/// This lets hyper's connection driver think it sent a request (its encoded bytes
|
||||
/// go to /dev/null), while correctly parsing the response that the upstream server
|
||||
/// sends in reply to our raw-written request.
|
||||
struct WriteFilter<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> WriteFilter<S> {
|
||||
fn new(inner: S) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for WriteFilter<S> {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
// Pass reads through to the underlying stream
|
||||
let inner = std::pin::Pin::new(&mut self.get_mut().inner);
|
||||
inner.poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
// Discard all writes — pretend they succeeded
|
||||
std::task::Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub mod srv {
|
||||
pub const STOPPED: &str = "SRV-002";
|
||||
pub const STOP_TIMEOUT: &str = "SRV-003";
|
||||
pub const TASK_ERROR: &str = "SRV-004";
|
||||
pub const ACCEPT_ERR: &str = "SRV-005";
|
||||
pub const CONN_ERR: &str = "SRV-006";
|
||||
}
|
||||
|
||||
/// 转发器日志码
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod hyper_client;
|
||||
pub mod log_codes;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
@@ -23,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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
use super::auth::AuthInfo;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 供应商适配器 Trait
|
||||
@@ -14,116 +13,36 @@ use serde_json::Value;
|
||||
/// - URL 构建
|
||||
/// - 认证信息提取和头部注入
|
||||
/// - 请求/响应格式转换(可选)
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub struct ClaudeAdapter;
|
||||
///
|
||||
/// impl ProviderAdapter for ClaudeAdapter {
|
||||
/// fn name(&self) -> &'static str { "Claude" }
|
||||
///
|
||||
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
/// // 从 provider 配置中提取 base_url
|
||||
/// }
|
||||
///
|
||||
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
/// // 从 provider 配置中提取认证信息
|
||||
/// }
|
||||
///
|
||||
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
|
||||
/// }
|
||||
///
|
||||
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
/// // 添加认证头
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait ProviderAdapter: Send + Sync {
|
||||
/// 适配器名称(用于日志和调试)
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// 从 Provider 配置中提取 base_url
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
|
||||
/// * `Err(ProxyError)` - 提取失败
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
|
||||
|
||||
/// 从 Provider 配置中提取认证信息
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(AuthInfo)` - 提取到的认证信息
|
||||
/// * `None` - 未找到认证信息
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
|
||||
|
||||
/// 构建请求 URL
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `base_url` - 基础 URL
|
||||
/// * `endpoint` - 请求端点(如 `/v1/messages`)
|
||||
///
|
||||
/// # Returns
|
||||
/// 完整的请求 URL
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
|
||||
|
||||
/// 添加认证头到请求
|
||||
/// Return auth headers as `(name, value)` pairs.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - reqwest RequestBuilder
|
||||
/// * `auth` - 认证信息
|
||||
///
|
||||
/// # Returns
|
||||
/// 添加了认证头的 RequestBuilder
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
||||
/// The forwarder inserts these at the position of the original auth header
|
||||
/// so that header order is preserved.
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)>;
|
||||
|
||||
/// 是否需要格式转换
|
||||
///
|
||||
/// 默认返回 `false`(透传模式)。
|
||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 转换请求体
|
||||
///
|
||||
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
|
||||
/// 默认实现直接返回原始请求体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始请求体
|
||||
/// * `provider` - Provider 配置(用于获取模型映射等)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的请求体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// 转换响应体
|
||||
///
|
||||
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
|
||||
/// 默认实现直接返回原始响应体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始响应体
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的响应体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
///
|
||||
/// Note: 响应转换将在 handler 层集成,目前预留接口
|
||||
#[allow(dead_code)]
|
||||
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// 获取 Claude 供应商的 API 格式
|
||||
///
|
||||
@@ -66,6 +65,30 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
matches!(api_format, "openai_chat" | "openai_responses")
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 适配器
|
||||
pub struct ClaudeAdapter;
|
||||
|
||||
@@ -298,7 +321,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
//
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
//
|
||||
let mut base = format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
@@ -310,52 +333,53 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base = base.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
// GitHub Copilot 不需要 ?beta=true 参数
|
||||
if base_url.contains("githubcopilot.com") {
|
||||
return base;
|
||||
}
|
||||
|
||||
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
|
||||
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions,
|
||||
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
|
||||
if endpoint.contains("/v1/messages")
|
||||
&& !endpoint.contains("/v1/chat/completions")
|
||||
&& !endpoint.contains('?')
|
||||
{
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
use http::{HeaderName, HeaderValue};
|
||||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||||
// 这里不再设置 anthropic-version,避免 header 重复
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
match auth.strategy {
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key
|
||||
AuthStrategy::Anthropic => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key),
|
||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||
AuthStrategy::ClaudeAuth => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
|
||||
vec![(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
// OpenRouter: Bearer
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("editor-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_EDITOR_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("editor-plugin-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_PLUGIN_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("copilot-integration-id"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_INTEGRATION_ID),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("user-agent"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_USER_AGENT),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-github-api-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("openai-intent"),
|
||||
HeaderValue::from_static("conversation-panel"),
|
||||
),
|
||||
]
|
||||
}
|
||||
// GitHub Copilot: Bearer + 统一指纹头
|
||||
AuthStrategy::GitHubCopilot => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION)
|
||||
.header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID)
|
||||
.header("user-agent", super::copilot_auth::COPILOT_USER_AGENT)
|
||||
.header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel"),
|
||||
_ => request,
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,19 +404,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
|
||||
match self.get_api_format(provider) {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
_ => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
}
|
||||
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
@@ -578,23 +590,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_other_endpoints() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 非 /v1/messages 端点不添加 ?beta=true
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||||
}
|
||||
@@ -602,16 +611,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_preserve_existing_query() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 已有查询参数时不重复添加
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_github_copilot() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let url = adapter.build_url("https://api.githubcopilot.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.githubcopilot.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// OpenAI Chat Completions 端点不添加 ?beta=true
|
||||
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
|
||||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||
}
|
||||
@@ -797,4 +810,25 @@ mod tests {
|
||||
// GitHub Copilot always needs transform
|
||||
assert!(adapter.needs_transform(&copilot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_responses() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
|
||||
|
||||
assert_eq!(transformed["model"], "gpt-5.4");
|
||||
assert!(transformed.get("input").is_some());
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use regex::Regex;
|
||||
use reqwest::RequestBuilder;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// 官方 Codex 客户端 User-Agent 正则
|
||||
@@ -174,8 +173,12 @@ impl ProviderAdapter for CodexAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
vec![(
|
||||
http::HeaderName::from_static("authorization"),
|
||||
http::HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,8 @@ pub struct CopilotAuthManager {
|
||||
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
/// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新)
|
||||
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
|
||||
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
|
||||
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
|
||||
/// HTTP 客户端
|
||||
http_client: Client,
|
||||
/// 存储路径
|
||||
@@ -330,6 +332,7 @@ impl CopilotAuthManager {
|
||||
default_account_id: Arc::new(RwLock::new(None)),
|
||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
pending_migration: Arc::new(RwLock::new(None)),
|
||||
@@ -338,7 +341,7 @@ impl CopilotAuthManager {
|
||||
|
||||
// 尝试从磁盘加载(同步,不发起网络请求)
|
||||
if let Err(e) = manager.load_from_disk_sync() {
|
||||
log::warn!("[CopilotAuth] 加载存储失败: {}", e);
|
||||
log::warn!("[CopilotAuth] 加载存储失败: {e}");
|
||||
}
|
||||
|
||||
manager
|
||||
@@ -361,7 +364,7 @@ impl CopilotAuthManager {
|
||||
|
||||
/// 移除指定账号
|
||||
pub async fn remove_account(&self, account_id: &str) -> Result<(), CopilotAuthError> {
|
||||
log::info!("[CopilotAuth] 移除账号: {}", account_id);
|
||||
log::info!("[CopilotAuth] 移除账号: {account_id}");
|
||||
|
||||
{
|
||||
let mut accounts = self.accounts.write().await;
|
||||
@@ -375,6 +378,10 @@ impl CopilotAuthManager {
|
||||
let mut tokens = self.copilot_tokens.write().await;
|
||||
tokens.remove(account_id);
|
||||
}
|
||||
{
|
||||
let mut models = self.copilot_models.write().await;
|
||||
models.remove(account_id);
|
||||
}
|
||||
{
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.remove(account_id);
|
||||
@@ -475,8 +482,7 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::NetworkError(format!(
|
||||
"GitHub 设备码请求失败: {} - {}",
|
||||
status, text
|
||||
"GitHub 设备码请求失败: {status} - {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -574,10 +580,7 @@ impl CopilotAuthManager {
|
||||
}
|
||||
|
||||
// 需要刷新
|
||||
log::info!(
|
||||
"[CopilotAuth] 账号 {} 的 Copilot Token 需要刷新",
|
||||
account_id
|
||||
);
|
||||
log::info!("[CopilotAuth] 账号 {account_id} 的 Copilot Token 需要刷新");
|
||||
|
||||
let refresh_lock = self.get_refresh_lock(account_id).await;
|
||||
let _refresh_guard = refresh_lock.lock().await;
|
||||
@@ -629,15 +632,36 @@ impl CopilotAuthManager {
|
||||
pub async fn fetch_models_for_account(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||
self.ensure_migration_complete().await?;
|
||||
|
||||
{
|
||||
let models = self.copilot_models.read().await;
|
||||
if let Some(cached) = models.get(account_id) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let models = self.fetch_models_for_account_uncached(account_id).await?;
|
||||
{
|
||||
let mut cache = self.copilot_models.write().await;
|
||||
cache.insert(account_id.to_string(), models.clone());
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
async fn fetch_models_for_account_uncached(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||
let copilot_token = self.get_valid_token_for_account(account_id).await?;
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 可用模型", account_id);
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_MODELS_URL)
|
||||
.header("Authorization", format!("Bearer {}", copilot_token))
|
||||
.header("Authorization", format!("Bearer {copilot_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("copilot-integration-id", "vscode-chat")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
@@ -651,8 +675,7 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"获取模型列表失败: {} - {}",
|
||||
status, text
|
||||
"获取模型列表失败: {status} - {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -678,6 +701,18 @@ impl CopilotAuthManager {
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
pub async fn get_model_vendor_for_account(
|
||||
&self,
|
||||
account_id: &str,
|
||||
model_id: &str,
|
||||
) -> Result<Option<String>, CopilotAuthError> {
|
||||
let models = self.fetch_models_for_account(account_id).await?;
|
||||
Ok(models
|
||||
.into_iter()
|
||||
.find(|model| model.id == model_id)
|
||||
.map(|model| model.vendor))
|
||||
}
|
||||
|
||||
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
|
||||
pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||
match self.resolve_default_account_id().await {
|
||||
@@ -686,6 +721,16 @@ impl CopilotAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_model_vendor(
|
||||
&self,
|
||||
model_id: &str,
|
||||
) -> Result<Option<String>, CopilotAuthError> {
|
||||
match self.resolve_default_account_id().await {
|
||||
Some(id) => self.get_model_vendor_for_account(&id, model_id).await,
|
||||
None => Err(CopilotAuthError::GitHubTokenInvalid),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定账号的 Copilot 使用量信息
|
||||
pub async fn fetch_usage_for_account(
|
||||
&self,
|
||||
@@ -699,12 +744,12 @@ impl CopilotAuthManager {
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
};
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 使用量", account_id);
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_USAGE_URL)
|
||||
.header("Authorization", format!("token {}", github_token))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", COPILOT_PLUGIN_VERSION)
|
||||
@@ -721,8 +766,7 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"获取使用量失败: {} - {}",
|
||||
status, text
|
||||
"获取使用量失败: {status} - {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -950,7 +994,7 @@ impl CopilotAuthManager {
|
||||
let response = self
|
||||
.http_client
|
||||
.get(GITHUB_USER_URL)
|
||||
.header("Authorization", format!("token {}", github_token))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.header("Editor-Version", COPILOT_EDITOR_VERSION)
|
||||
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
|
||||
@@ -977,12 +1021,12 @@ impl CopilotAuthManager {
|
||||
github_token: &str,
|
||||
account_id: &str,
|
||||
) -> Result<(), CopilotAuthError> {
|
||||
log::debug!("[CopilotAuth] 获取账号 {} 的 Copilot Token", account_id);
|
||||
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_TOKEN_URL)
|
||||
.header("Authorization", format!("token {}", github_token))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
.header("Editor-Version", COPILOT_EDITOR_VERSION)
|
||||
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
|
||||
@@ -1001,8 +1045,7 @@ impl CopilotAuthManager {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"{}: {}",
|
||||
status, text
|
||||
"{status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1085,7 +1128,7 @@ impl CopilotAuthManager {
|
||||
.fetch_copilot_token_with_github_token(&legacy_token, &account_id)
|
||||
.await
|
||||
{
|
||||
log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {}", e);
|
||||
log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {e}");
|
||||
}
|
||||
|
||||
// 添加账号
|
||||
@@ -1099,7 +1142,7 @@ impl CopilotAuthManager {
|
||||
"Legacy Copilot auth migration failed: {e}"
|
||||
)))
|
||||
.await;
|
||||
log::warn!("[CopilotAuth] 迁移失败,旧 token 可能已失效: {}", e);
|
||||
log::warn!("[CopilotAuth] 迁移失败,旧 token 可能已失效: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1143,6 +1186,7 @@ impl CopilotAuthManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_copilot_token_expiry() {
|
||||
@@ -1315,4 +1359,59 @@ mod tests {
|
||||
Some("67890".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_model_vendor_from_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut models = manager.copilot_models.write().await;
|
||||
models.insert(
|
||||
"12345".to_string(),
|
||||
vec![
|
||||
CopilotModel {
|
||||
id: "gpt-5.4".to_string(),
|
||||
name: "GPT-5.4".to_string(),
|
||||
vendor: "OpenAI".to_string(),
|
||||
model_picker_enabled: true,
|
||||
},
|
||||
CopilotModel {
|
||||
id: "claude-sonnet-4".to_string(),
|
||||
name: "Claude Sonnet 4".to_string(),
|
||||
vendor: "Anthropic".to_string(),
|
||||
model_picker_enabled: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
let vendor = manager
|
||||
.get_model_vendor_for_account("12345", "gpt-5.4")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(vendor.as_deref(), Some("OpenAI"));
|
||||
|
||||
let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap();
|
||||
assert_eq!(default_vendor.as_deref(), Some("Anthropic"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Gemini 适配器
|
||||
pub struct GeminiAdapter;
|
||||
@@ -217,17 +216,26 @@ impl ProviderAdapter for GeminiAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
use http::{HeaderName, HeaderValue};
|
||||
match auth.strategy {
|
||||
// OAuth Bearer 认证
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
request
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-goog-api-client"),
|
||||
HeaderValue::from_static("GeminiCLI/1.0"),
|
||||
),
|
||||
]
|
||||
}
|
||||
// API Key 认证
|
||||
_ => request.header("x-goog-api-key", &auth.api_key),
|
||||
_ => vec![(
|
||||
HeaderName::from_static("x-goog-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ use serde::{Deserialize, Serialize};
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::{get_claude_api_format, ClaudeAdapter};
|
||||
pub use claude::{
|
||||
claude_api_format_needs_transform, get_claude_api_format,
|
||||
transform_claude_request_for_api_format, ClaudeAdapter,
|
||||
};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ struct ToolBlockState {
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
@@ -598,7 +598,9 @@ mod tests {
|
||||
"data: [DONE]\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(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
@@ -686,7 +688,9 @@ mod tests {
|
||||
"data: [DONE]\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(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
|
||||
@@ -96,8 +96,8 @@ fn resolve_content_index(
|
||||
///
|
||||
/// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map
|
||||
/// SSE 解析支持 named events (event: + data: 行)
|
||||
pub fn create_anthropic_sse_stream_from_responses(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
@@ -109,6 +109,7 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
let mut index_by_key: HashMap<String, u32> = HashMap::new();
|
||||
let mut open_indices: HashSet<u32> = HashSet::new();
|
||||
let mut fallback_open_index: Option<u32> = None;
|
||||
let mut current_text_index: Option<u32> = None;
|
||||
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
||||
let mut last_tool_index: Option<u32> = None;
|
||||
|
||||
@@ -218,12 +219,18 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
if let Some(part) = data.get("part") {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str());
|
||||
if matches!(part_type, Some("output_text") | Some("refusal")) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
let index = if let Some(index) = current_text_index {
|
||||
index
|
||||
} else {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
current_text_index = Some(index);
|
||||
index
|
||||
};
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
@@ -250,12 +257,18 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
// ================================================
|
||||
"response.output_text.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
let index = if let Some(index) = current_text_index {
|
||||
index
|
||||
} else {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
current_text_index = Some(index);
|
||||
index
|
||||
};
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
@@ -290,12 +303,18 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
// ================================================
|
||||
"response.refusal.delta" => {
|
||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
let index = if let Some(index) = current_text_index {
|
||||
index
|
||||
} else {
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
&mut index_by_key,
|
||||
&mut fallback_open_index,
|
||||
);
|
||||
current_text_index = Some(index);
|
||||
index
|
||||
};
|
||||
|
||||
if !open_indices.contains(&index) {
|
||||
let start_event = json!({
|
||||
@@ -329,29 +348,7 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
// ================================================
|
||||
// response.content_part.done → content_block_stop
|
||||
// ================================================
|
||||
"response.content_part.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.content_part.done" => {}
|
||||
|
||||
// ================================================
|
||||
// response.output_item.added (function_call) → content_block_start (tool_use)
|
||||
@@ -361,6 +358,20 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if item_type == "function_call" {
|
||||
has_tool_use = true;
|
||||
if let Some(index) = current_text_index.take() {
|
||||
if open_indices.remove(&index) {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
// 确保 message_start 已发送
|
||||
if !has_sent_message_start {
|
||||
let start_event = json!({
|
||||
@@ -521,12 +532,14 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
// response.refusal.done → content_block_stop
|
||||
// ================================================
|
||||
"response.refusal.done" => {
|
||||
let key = content_part_key(&data);
|
||||
let index = if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
};
|
||||
let index = current_text_index.take().or_else(|| {
|
||||
let key = content_part_key(&data);
|
||||
if let Some(k) = key {
|
||||
index_by_key.get(&k).copied()
|
||||
} else {
|
||||
fallback_open_index
|
||||
}
|
||||
});
|
||||
if let Some(index) = index {
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
@@ -553,6 +566,20 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
.or_else(|| data.get("text"))
|
||||
.and_then(|d| d.as_str())
|
||||
{
|
||||
if let Some(index) = current_text_index.take() {
|
||||
if open_indices.remove(&index) {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
let index = resolve_content_index(
|
||||
&data,
|
||||
&mut next_content_index,
|
||||
@@ -674,8 +701,23 @@ pub fn create_anthropic_sse_stream_from_responses(
|
||||
|
||||
// Lifecycle events that don't need Anthropic counterparts.
|
||||
// Listed explicitly so new events trigger a match-completeness review.
|
||||
"response.output_text.done"
|
||||
| "response.output_item.done"
|
||||
"response.output_text.done" => {
|
||||
if let Some(index) = current_text_index.take() {
|
||||
if open_indices.remove(&index) {
|
||||
let stop_event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(stop_sse));
|
||||
}
|
||||
if fallback_open_index == Some(index) {
|
||||
fallback_open_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.output_item.done"
|
||||
| "response.in_progress" => {}
|
||||
|
||||
// Any other unknown/future events — silently skip.
|
||||
@@ -758,7 +800,9 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":3}}}\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;
|
||||
|
||||
@@ -800,7 +844,9 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":8,\"output_tokens\":4}}}\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 merged = chunks
|
||||
@@ -871,7 +917,9 @@ mod tests {
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":10}}}\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 merged = chunks
|
||||
@@ -902,4 +950,83 @@ mod tests {
|
||||
);
|
||||
assert!(merged.contains("\"stop_reason\":\"end_turn\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_text_parts_are_merged_into_one_text_block() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_merge\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.content_part.done\n",
|
||||
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||
"event: response.content_part.added\n",
|
||||
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.content_part.done\n",
|
||||
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.output_text.done\n",
|
||||
"data: {\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
|
||||
);
|
||||
|
||||
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
|
||||
.into_iter()
|
||||
.flat_map(|chunk| {
|
||||
let bytes = chunk.unwrap();
|
||||
let text = String::from_utf8_lossy(bytes.as_ref()).to_string();
|
||||
text.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
block.lines().find_map(|line| {
|
||||
strip_sse_field(line, "data")
|
||||
.and_then(|payload| serde_json::from_str::<Value>(payload).ok())
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let text_starts = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("text")
|
||||
})
|
||||
.count();
|
||||
let text_stops = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_stop")
|
||||
})
|
||||
.count();
|
||||
let text_deltas: Vec<String> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str()) == Some("text_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
event
|
||||
.pointer("/delta/text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(text_starts, 1);
|
||||
assert_eq!(text_stops, 1);
|
||||
assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
|
||||
_ => None, // unknown value — do not inject
|
||||
_ => None, // unknown value — do not inject
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
use super::{
|
||||
handler_config::UsageParserConfig,
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::ProxyResponse,
|
||||
server::ProxyState,
|
||||
sse::strip_sse_field,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::header::HeaderMap;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
io::Read,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
@@ -24,24 +26,123 @@ use std::{
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ============================================================================
|
||||
// 响应解压
|
||||
// ============================================================================
|
||||
|
||||
/// 根据 content-encoding 解压响应体字节
|
||||
///
|
||||
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
|
||||
match content_encoding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
"deflate" => {
|
||||
let mut decoder = flate2::read::DeflateDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
"br" => {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
_ => {
|
||||
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从响应头提取 content-encoding(忽略 identity 和 chunked)
|
||||
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("content-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty() && s != "identity")
|
||||
}
|
||||
|
||||
/// 移除在重建响应体后会失真的实体头。
|
||||
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
headers.remove(axum::http::header::TRANSFER_ENCODING);
|
||||
}
|
||||
|
||||
/// 读取响应体并在需要时解压,确保 headers 与返回 body 一致。
|
||||
///
|
||||
/// `body_timeout`: 整包超时。当非零时用 `tokio::time::timeout` 包住 `.bytes()` 调用,
|
||||
/// 防止上游发完响应头后卡住 body 导致请求永远挂住。
|
||||
/// 传入 `Duration::ZERO` 表示不启用超时(故障转移关闭时)。
|
||||
pub(crate) async fn read_decoded_body(
|
||||
response: ProxyResponse,
|
||||
tag: &str,
|
||||
body_timeout: Duration,
|
||||
) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> {
|
||||
let mut headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
let raw_bytes = if body_timeout.is_zero() {
|
||||
response.bytes().await?
|
||||
} else {
|
||||
tokio::time::timeout(body_timeout, response.bytes())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
|
||||
body_timeout.as_secs()
|
||||
))
|
||||
})??
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[{tag}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||
status.as_u16(),
|
||||
raw_bytes.len(),
|
||||
format_headers(&headers)
|
||||
);
|
||||
|
||||
let mut body_bytes = raw_bytes.clone();
|
||||
let mut decoded = false;
|
||||
|
||||
if let Some(encoding) = get_content_encoding(&headers) {
|
||||
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
|
||||
match decompress_body(&encoding, &raw_bytes) {
|
||||
Ok(decompressed) => {
|
||||
body_bytes = Bytes::from(decompressed);
|
||||
decoded = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if decoded {
|
||||
strip_entity_headers_for_rebuilt_body(&mut headers);
|
||||
}
|
||||
|
||||
Ok((headers, status, body_bytes))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 公共接口
|
||||
// ============================================================================
|
||||
|
||||
/// 检测响应是否为 SSE 流式响应
|
||||
#[inline]
|
||||
pub fn is_sse_response(response: &reqwest::Response) -> bool {
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
pub fn is_sse_response(response: &ProxyResponse) -> bool {
|
||||
response.is_sse()
|
||||
}
|
||||
|
||||
/// 处理流式响应
|
||||
pub async fn handle_streaming(
|
||||
response: reqwest::Response,
|
||||
response: ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
@@ -53,6 +154,15 @@ pub async fn handle_streaming(
|
||||
status.as_u16(),
|
||||
format_headers(response.headers())
|
||||
);
|
||||
// 检查流式响应是否被压缩(SSE 通常不压缩,如果压缩则 SSE 解析会失败)
|
||||
if let Some(encoding) = get_content_encoding(response.headers()) {
|
||||
log::warn!(
|
||||
"[{}] 流式响应含 content-encoding={encoding},SSE 解析可能失败。\
|
||||
上游在 accept-encoding 透传后压缩了 SSE 流。",
|
||||
ctx.tag
|
||||
);
|
||||
}
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
// 复制响应头
|
||||
@@ -61,9 +171,7 @@ pub async fn handle_streaming(
|
||||
}
|
||||
|
||||
// 创建字节流
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
|
||||
let stream = response.bytes_stream();
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||
@@ -87,26 +195,20 @@ pub async fn handle_streaming(
|
||||
|
||||
/// 处理非流式响应
|
||||
pub async fn handle_non_streaming(
|
||||
response: reqwest::Response,
|
||||
response: ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> Result<Response, ProxyError> {
|
||||
let response_headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
|
||||
// 读取响应体
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
log::debug!(
|
||||
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||
ctx.tag,
|
||||
status.as_u16(),
|
||||
body_bytes.len(),
|
||||
format_headers(&response_headers)
|
||||
);
|
||||
// 整包超时:仅在故障转移开启且配置值非零时生效
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
|
||||
log::debug!(
|
||||
"[{}] 上游响应体内容: {}",
|
||||
@@ -190,7 +292,7 @@ pub async fn handle_non_streaming(
|
||||
///
|
||||
/// 根据响应类型自动选择流式或非流式处理
|
||||
pub async fn process_response(
|
||||
response: reqwest::Response,
|
||||
response: ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
//! HTTP代理服务器
|
||||
//!
|
||||
//! 基于Axum的HTTP服务器,处理代理请求
|
||||
//!
|
||||
//! Uses a manual hyper HTTP/1.1 accept loop with `preserve_header_case(true)` so
|
||||
//! that the original header-name casing from the CLI client is captured in a
|
||||
//! `HeaderCaseMap` extension. This map is later forwarded to the upstream via
|
||||
//! the hyper-based HTTP client, producing wire-level header casing identical to
|
||||
//! a direct (non-proxied) CLI request.
|
||||
|
||||
use super::{
|
||||
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
|
||||
@@ -12,6 +18,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
@@ -114,15 +121,77 @@ impl ProxyServer {
|
||||
// 记录启动时间
|
||||
*self.state.start_time.write().await = Some(std::time::Instant::now());
|
||||
|
||||
// 启动服务器
|
||||
// 启动服务器 — 使用手动 hyper HTTP/1.1 accept loop
|
||||
// 开启 preserve_header_case 以捕获客户端请求头的原始大小写
|
||||
let state = self.state.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
shutdown_rx.await.ok();
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
let mut shutdown_rx = shutdown_rx;
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
let (stream, _remote_addr) = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::error!("[{SRV}] accept 失败: {e}", SRV = log_srv::ACCEPT_ERR);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
// Peek raw TCP bytes to capture original header casing
|
||||
// before hyper parses (and lowercases) the header names.
|
||||
let original_cases = {
|
||||
let mut peek_buf = vec![0u8; 8192];
|
||||
match stream.peek(&mut peek_buf).await {
|
||||
Ok(n) => {
|
||||
let cases = super::hyper_client::OriginalHeaderCases::from_raw_bytes(&peek_buf[..n]);
|
||||
log::debug!(
|
||||
"[ProxyServer] Peeked {} bytes, captured {} header casings",
|
||||
n, cases.cases.len()
|
||||
);
|
||||
cases
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[ProxyServer] peek failed (non-fatal): {e}");
|
||||
super::hyper_client::OriginalHeaderCases::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// service_fn 将 axum Router(tower::Service)桥接到 hyper
|
||||
let service = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
|
||||
let mut router = app.clone();
|
||||
let cases = original_cases.clone();
|
||||
async move {
|
||||
// 将 hyper::body::Incoming 转为 axum::body::Body,保留 extensions
|
||||
let (mut parts, body) = req.into_parts();
|
||||
|
||||
// Insert our own header case map alongside hyper's internal one
|
||||
parts.extensions.insert(cases);
|
||||
|
||||
let body = axum::body::Body::new(body);
|
||||
let axum_req = http::Request::from_parts(parts, body);
|
||||
<Router as tower::Service<http::Request<axum::body::Body>>>::call(&mut router, axum_req).await
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = hyper::server::conn::http1::Builder::new()
|
||||
.preserve_header_case(true)
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
{
|
||||
// Connection reset / broken pipe 等在代理场景下很常见,debug 级别
|
||||
log::debug!("[{SRV}] connection error: {e}", SRV = log_srv::CONN_ERR);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ = &mut shutdown_rx => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 服务器停止后更新状态
|
||||
state.status.write().await.running = false;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
}
|
||||
|
||||
if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
|
||||
log::info!("[OPT] thinking: adaptive({})", model);
|
||||
log::info!("[OPT] thinking: adaptive({model})");
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
body["output_config"] = json!({"effort": "max"});
|
||||
append_beta(body, "context-1m-2025-08-07");
|
||||
@@ -33,7 +33,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
|
||||
}
|
||||
|
||||
// legacy path
|
||||
log::info!("[OPT] thinking: legacy({})", model);
|
||||
log::info!("[OPT] thinking: legacy({model})");
|
||||
|
||||
let max_tokens = body
|
||||
.get("max_tokens")
|
||||
|
||||
+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() {
|
||||
|
||||
@@ -957,7 +957,7 @@ impl SkillService {
|
||||
if source_path.is_none() {
|
||||
source_path = Some(skill_path);
|
||||
}
|
||||
log::debug!("Skill '{}' found in source '{}'", dir_name, label);
|
||||
log::debug!("Skill '{dir_name}' found in source '{label}'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ use std::time::Instant;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::transform::anthropic_to_openai;
|
||||
use crate::proxy::providers::copilot_auth;
|
||||
use crate::proxy::providers::transform::anthropic_to_openai;
|
||||
use crate::proxy::providers::transform_responses::anthropic_to_responses;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||
|
||||
/// 健康状态枚举
|
||||
@@ -87,15 +88,21 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
// 合并供应商单独配置和全局配置
|
||||
let effective_config = Self::merge_provider_config(provider, config);
|
||||
let mut last_result = None;
|
||||
|
||||
for attempt in 0..=effective_config.max_retries {
|
||||
let result =
|
||||
Self::check_once(app_type, provider, &effective_config, auth_override.clone())
|
||||
.await;
|
||||
let result = Self::check_once(
|
||||
app_type,
|
||||
provider,
|
||||
&effective_config,
|
||||
auth_override.clone(),
|
||||
claude_api_format_override.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match &result {
|
||||
Ok(r) if r.success => {
|
||||
@@ -184,6 +191,7 @@ impl StreamCheckService {
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
auth_override: Option<AuthInfo>,
|
||||
claude_api_format_override: Option<String>,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
let start = Instant::now();
|
||||
let adapter = get_adapter(app_type);
|
||||
@@ -214,6 +222,7 @@ impl StreamCheckService {
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
provider,
|
||||
claude_api_format_override.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -225,6 +234,7 @@ impl StreamCheckService {
|
||||
&model_to_test,
|
||||
test_prompt,
|
||||
request_timeout,
|
||||
provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -293,6 +303,7 @@ impl StreamCheckService {
|
||||
/// 根据供应商的 api_format 选择请求格式:
|
||||
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
|
||||
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
@@ -301,6 +312,7 @@ impl StreamCheckService {
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
provider: &Provider,
|
||||
claude_api_format_override: Option<&str>,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
|
||||
@@ -318,37 +330,31 @@ impl StreamCheckService {
|
||||
})
|
||||
.unwrap_or("anthropic");
|
||||
|
||||
let is_openai_chat = is_github_copilot || api_format == "openai_chat";
|
||||
let effective_api_format = claude_api_format_override.unwrap_or(api_format);
|
||||
|
||||
// URL:
|
||||
// - GitHub Copilot: /chat/completions (no /v1 prefix)
|
||||
// - OpenAI-compatible: /v1/chat/completions
|
||||
// - Anthropic native: /v1/messages?beta=true
|
||||
let url = if is_github_copilot {
|
||||
format!("{base}/chat/completions")
|
||||
} else if is_openai_chat {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
}
|
||||
} else {
|
||||
// ?beta=true is required by some relay services to verify request origin
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/messages?beta=true")
|
||||
} else {
|
||||
format!("{base}/v1/messages?beta=true")
|
||||
}
|
||||
};
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
let is_openai_chat = effective_api_format == "openai_chat";
|
||||
let is_openai_responses = effective_api_format == "openai_responses";
|
||||
let url =
|
||||
Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url);
|
||||
|
||||
// Build from Anthropic-native shape first, then convert for OpenAI-compatible targets.
|
||||
let max_tokens = if is_openai_responses { 16 } else { 1 };
|
||||
|
||||
// Build from Anthropic-native shape first, then convert for configured targets.
|
||||
let anthropic_body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{ "role": "user", "content": test_prompt }],
|
||||
"stream": true
|
||||
});
|
||||
let body = if is_openai_chat {
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id))
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_openai_chat {
|
||||
anthropic_to_openai(anthropic_body, Some(&provider.id))
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else {
|
||||
@@ -365,12 +371,18 @@ impl StreamCheckService {
|
||||
.header("accept-encoding", "identity")
|
||||
.header("user-agent", copilot_auth::COPILOT_USER_AGENT)
|
||||
.header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", copilot_auth::COPILOT_PLUGIN_VERSION)
|
||||
.header("copilot-integration-id", copilot_auth::COPILOT_INTEGRATION_ID)
|
||||
.header(
|
||||
"editor-plugin-version",
|
||||
copilot_auth::COPILOT_PLUGIN_VERSION,
|
||||
)
|
||||
.header(
|
||||
"copilot-integration-id",
|
||||
copilot_auth::COPILOT_INTEGRATION_ID,
|
||||
)
|
||||
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
|
||||
.header("openai-intent", "conversation-panel");
|
||||
} else if is_openai_chat {
|
||||
// OpenAI-compatible: Bearer auth + standard headers only
|
||||
} else if is_openai_chat || is_openai_responses {
|
||||
// OpenAI-compatible targets: Bearer auth + SSE headers only
|
||||
request_builder = request_builder
|
||||
.header("authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("content-type", "application/json")
|
||||
@@ -455,18 +467,14 @@ impl StreamCheckService {
|
||||
model: &str,
|
||||
test_prompt: &str,
|
||||
timeout: std::time::Duration,
|
||||
provider: &Provider,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Codex CLI 的 base_url 语义:base_url 是 API base(可能已包含 /v1 或其他自定义前缀),
|
||||
// Responses 端点为 `/responses`。
|
||||
//
|
||||
// 兼容:如果 base_url 配成纯 origin(如 https://api.openai.com),则需要补 `/v1`。
|
||||
// 优先尝试 `{base}/responses`,若 404 再回退 `{base}/v1/responses`。
|
||||
let urls = if base.ends_with("/v1") {
|
||||
vec![format!("{base}/responses")]
|
||||
} else {
|
||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||
};
|
||||
let is_full_url = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
let urls = Self::resolve_codex_stream_urls(base_url, is_full_url);
|
||||
|
||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
||||
@@ -724,30 +732,64 @@ impl StreamCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resolve_claude_stream_url(
|
||||
base_url: &str,
|
||||
auth_strategy: AuthStrategy,
|
||||
api_format: &str,
|
||||
is_full_url: bool,
|
||||
) -> String {
|
||||
if is_full_url {
|
||||
return base_url.to_string();
|
||||
}
|
||||
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot;
|
||||
let is_openai_chat = is_github_copilot || api_format == "openai_chat";
|
||||
|
||||
if is_github_copilot {
|
||||
if is_github_copilot && api_format == "openai_responses" {
|
||||
format!("{base}/v1/responses")
|
||||
} else if is_github_copilot {
|
||||
format!("{base}/chat/completions")
|
||||
} else if is_openai_chat {
|
||||
} else if api_format == "openai_responses" {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/responses")
|
||||
} else {
|
||||
format!("{base}/v1/responses")
|
||||
}
|
||||
} else if api_format == "openai_chat" {
|
||||
if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
}
|
||||
} else if base.ends_with("/v1") {
|
||||
format!("{base}/messages?beta=true")
|
||||
format!("{base}/messages")
|
||||
} else {
|
||||
format!("{base}/v1/messages?beta=true")
|
||||
format!("{base}/v1/messages")
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_codex_stream_urls(base_url: &str, is_full_url: bool) -> Vec<String> {
|
||||
if is_full_url {
|
||||
return vec![base_url.to_string()];
|
||||
}
|
||||
|
||||
let base = base_url.trim_end_matches('/');
|
||||
|
||||
if base.ends_with("/v1") {
|
||||
vec![format!("{base}/responses")]
|
||||
} else {
|
||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_effective_test_model(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
) -> String {
|
||||
let effective_config = Self::merge_provider_config(provider, config);
|
||||
Self::resolve_test_model(app_type, provider, &effective_config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -851,36 +893,106 @@ mod tests {
|
||||
assert_eq!(bearer, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_full_url_mode() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://relay.example/v1/chat/completions",
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_github_copilot() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://api.githubcopilot.com",
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"anthropic",
|
||||
"openai_chat",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_github_copilot_responses() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://api.githubcopilot.com",
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"openai_responses",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_openai_chat() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://example.com/v1",
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_openai_responses() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://example.com/v1",
|
||||
AuthStrategy::Bearer,
|
||||
"openai_responses",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_anthropic() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://api.anthropic.com",
|
||||
AuthStrategy::Anthropic,
|
||||
"anthropic",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_full_url_mode() {
|
||||
let urls = StreamCheckService::resolve_codex_stream_urls(
|
||||
"https://relay.example/custom/responses",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(urls, vec!["https://relay.example/custom/responses"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_v1_base() {
|
||||
let urls =
|
||||
StreamCheckService::resolve_codex_stream_urls("https://api.openai.com/v1", false);
|
||||
|
||||
assert_eq!(urls, vec!["https://api.openai.com/v1/responses"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_origin_base() {
|
||||
let urls = StreamCheckService::resolve_codex_stream_urls("https://api.openai.com", false);
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"https://api.openai.com/responses",
|
||||
"https://api.openai.com/v1/responses",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -463,12 +463,10 @@ fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Re
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{} (本地 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地 db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{} (local db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -476,12 +474,10 @@ fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Re
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{} (本地最高支持 db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地最高支持 db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{} (local supports up to db-v{DB_COMPAT_VERSION})",
|
||||
db_compat_version
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local supports up to db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -661,11 +657,8 @@ fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), Ap
|
||||
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_too_large",
|
||||
format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb),
|
||||
format!(
|
||||
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||||
max_mb
|
||||
),
|
||||
format!("artifact {artifact_name} 超过下载上限({max_mb} MB)"),
|
||||
format!("Artifact {artifact_name} exceeds download limit ({max_mb} MB)"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -350,8 +350,8 @@ fn copy_entry_with_total_limit<R: Read, W: Write>(
|
||||
let max_mb = max_total_bytes / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_large",
|
||||
format!("skills.zip 解压后体积超过上限({} MB)", max_mb),
|
||||
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
|
||||
format!("skills.zip 解压后体积超过上限({max_mb} MB)"),
|
||||
format!("skills.zip extracted size exceeds limit ({max_mb} MB)"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ fn scan_sessions_sqlite() -> Vec<SessionMeta> {
|
||||
},
|
||||
created_at: Some(created),
|
||||
last_active_at: Some(updated),
|
||||
source_path: Some(format!("sqlite:{}:{}", db_display, session_id)),
|
||||
source_path: Some(format!("sqlite:{db_display}:{session_id}")),
|
||||
resume_command: Some(format!("opencode session resume {session_id}")),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(验证存在性)
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::store::AppState;
|
||||
pub struct TrayTexts {
|
||||
pub show_main: &'static str,
|
||||
pub no_provider_hint: &'static str,
|
||||
pub lightweight_mode: &'static str,
|
||||
pub quit: &'static str,
|
||||
pub _auto_label: &'static str,
|
||||
}
|
||||
@@ -24,6 +25,7 @@ impl TrayTexts {
|
||||
"en" => Self {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
lightweight_mode: "Lightweight Mode",
|
||||
quit: "Quit",
|
||||
_auto_label: "Auto (Failover)",
|
||||
},
|
||||
@@ -31,12 +33,14 @@ impl TrayTexts {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
lightweight_mode: "軽量モード",
|
||||
quit: "終了",
|
||||
_auto_label: "自動 (フェイルオーバー)",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
lightweight_mode: "轻量模式",
|
||||
quit: "退出",
|
||||
_auto_label: "自动 (故障转移)",
|
||||
},
|
||||
@@ -382,6 +386,18 @@ pub fn create_tray_menu(
|
||||
menu_builder = menu_builder.separator();
|
||||
}
|
||||
|
||||
let lightweight_item = CheckMenuItem::with_id(
|
||||
app,
|
||||
"lightweight_mode",
|
||||
tray_texts.lightweight_mode,
|
||||
true,
|
||||
crate::lightweight::is_lightweight_mode(),
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
|
||||
|
||||
menu_builder = menu_builder.item(&lightweight_item).separator();
|
||||
|
||||
// 退出菜单(分隔符已在上面的 section 循环中添加)
|
||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||
@@ -393,6 +409,20 @@ pub fn create_tray_menu(
|
||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||
}
|
||||
|
||||
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
|
||||
use crate::store::AppState;
|
||||
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
if let Ok(new_menu) = create_tray_menu(app, state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
log::error!("刷新托盘菜单失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||
use tauri::ActivationPolicy;
|
||||
@@ -430,6 +460,19 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
{
|
||||
apply_tray_policy(app, true);
|
||||
}
|
||||
} else if crate::lightweight::is_lightweight_mode() {
|
||||
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
|
||||
log::error!("退出轻量模式重建窗口失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
"lightweight_mode" => {
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
|
||||
+50
-8
@@ -255,7 +255,7 @@ function App() {
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp);
|
||||
} = useProviderActions(activeApp, isProxyRunning);
|
||||
|
||||
const disableOmoMutation = useDisableCurrentOmo();
|
||||
const handleDisableOmo = () => {
|
||||
@@ -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}
|
||||
|
||||
@@ -42,9 +42,9 @@ export function AddProviderDialog({
|
||||
const { t } = useTranslation();
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"app-specific" | "universal"
|
||||
>("app-specific");
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
const [universalFormOpen, setUniversalFormOpen] = useState(false);
|
||||
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
||||
useState<UniversalProviderPreset | null>(null);
|
||||
@@ -284,9 +284,7 @@ export function AddProviderDialog({
|
||||
{showUniversalTab ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "app-specific" | "universal")
|
||||
}
|
||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="app-specific">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -108,6 +108,10 @@ interface ClaudeFormFieldsProps {
|
||||
// Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)
|
||||
apiKeyField: ClaudeApiKeyField;
|
||||
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||
|
||||
// Full URL mode
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -149,6 +153,8 @@ export function ClaudeFormFields({
|
||||
onApiFormatChange,
|
||||
apiKeyField,
|
||||
onApiKeyFieldChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasAnyAdvancedValue = !!(
|
||||
@@ -160,8 +166,7 @@ export function ClaudeFormFields({
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
);
|
||||
const [advancedExpanded, setAdvancedExpanded] =
|
||||
useState(hasAnyAdvancedValue);
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
useEffect(() => {
|
||||
@@ -379,6 +384,9 @@ export function ClaudeFormFields({
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
showFullUrlToggle={true}
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -400,10 +408,7 @@ export function ClaudeFormFields({
|
||||
|
||||
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
|
||||
{shouldShowModelSelector && (
|
||||
<Collapsible
|
||||
open={advancedExpanded}
|
||||
onOpenChange={setAdvancedExpanded}
|
||||
>
|
||||
<Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import {
|
||||
@@ -175,10 +181,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
}
|
||||
} else {
|
||||
toml = removeCodexTopLevelField(toml, "model_context_window");
|
||||
toml = removeCodexTopLevelField(
|
||||
toml,
|
||||
"model_auto_compact_token_limit",
|
||||
);
|
||||
toml = removeCodexTopLevelField(toml, "model_auto_compact_token_limit");
|
||||
}
|
||||
handleLocalChange(toml);
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@ interface CodexFormFieldsProps {
|
||||
shouldShowSpeedTest: boolean;
|
||||
codexBaseUrl: string;
|
||||
onBaseUrlChange: (url: string) => void;
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
isEndpointModalOpen: boolean;
|
||||
onEndpointModalToggle: (open: boolean) => void;
|
||||
onCustomEndpointsChange?: (endpoints: string[]) => void;
|
||||
@@ -49,6 +51,8 @@ export function CodexFormFields({
|
||||
shouldShowSpeedTest,
|
||||
codexBaseUrl,
|
||||
onBaseUrlChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
isEndpointModalOpen,
|
||||
onEndpointModalToggle,
|
||||
onCustomEndpointsChange,
|
||||
@@ -93,6 +97,9 @@ export function CodexFormFields({
|
||||
onChange={onBaseUrlChange}
|
||||
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
|
||||
hint={t("providerForm.codexApiHint")}
|
||||
showFullUrlToggle
|
||||
isFullUrl={isFullUrl}
|
||||
onFullUrlChange={onFullUrlChange}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -162,6 +164,11 @@ export function ProviderForm({
|
||||
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
|
||||
() => initialData?.meta?.endpointAutoSelect ?? true,
|
||||
);
|
||||
const supportsFullUrl = appId === "claude" || appId === "codex";
|
||||
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
|
||||
if (!supportsFullUrl) return false;
|
||||
return initialData?.meta?.isFullUrl ?? false;
|
||||
});
|
||||
|
||||
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
|
||||
() => initialData?.meta?.testConfig ?? { enabled: false },
|
||||
@@ -201,6 +208,9 @@ export function ProviderForm({
|
||||
setDraftCustomEndpoints([]);
|
||||
}
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
setLocalIsFullUrl(
|
||||
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
|
||||
);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||
setPricingConfig({
|
||||
@@ -212,7 +222,7 @@ export function ProviderForm({
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
});
|
||||
}, [appId, initialData]);
|
||||
}, [appId, initialData, supportsFullUrl]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
() => ({
|
||||
@@ -419,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 {
|
||||
@@ -569,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,
|
||||
@@ -597,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);
|
||||
|
||||
@@ -633,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;
|
||||
@@ -657,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;
|
||||
@@ -941,6 +1048,10 @@ export function ProviderForm({
|
||||
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localApiKeyField
|
||||
: undefined,
|
||||
isFullUrl:
|
||||
supportsFullUrl && category !== "official" && localIsFullUrl
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
@@ -1180,6 +1291,7 @@ export function ProviderForm({
|
||||
}
|
||||
|
||||
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
|
||||
setLocalIsFullUrl(false);
|
||||
|
||||
form.reset({
|
||||
name: preset.nameKey ? t(preset.nameKey) : preset.name,
|
||||
@@ -1240,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,
|
||||
@@ -1254,10 +1368,10 @@ export function ProviderForm({
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{existingOpencodeKeys.includes(
|
||||
{additiveExistingProviderKeys.includes(
|
||||
opencodeForm.opencodeProviderKey,
|
||||
) &&
|
||||
!isEditMode && (
|
||||
!isProviderKeyLocked && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("opencode.providerKeyDuplicate")}
|
||||
</p>
|
||||
@@ -1271,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>
|
||||
@@ -1299,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,
|
||||
@@ -1313,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>
|
||||
@@ -1330,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>
|
||||
@@ -1402,6 +1528,8 @@ export function ProviderForm({
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
apiKeyField={localApiKeyField}
|
||||
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1418,6 +1546,8 @@ export function ProviderForm({
|
||||
shouldShowSpeedTest={shouldShowSpeedTest}
|
||||
codexBaseUrl={codexBaseUrl}
|
||||
onBaseUrlChange={handleCodexBaseUrlChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
isEndpointModalOpen={isCodexEndpointModalOpen}
|
||||
onEndpointModalToggle={setIsCodexEndpointModalOpen}
|
||||
onCustomEndpointsChange={
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Zap } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Link2, Zap } from "lucide-react";
|
||||
|
||||
interface EndpointFieldProps {
|
||||
id: string;
|
||||
@@ -13,6 +14,9 @@ interface EndpointFieldProps {
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
showFullUrlToggle?: boolean;
|
||||
isFullUrl?: boolean;
|
||||
onFullUrlChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function EndpointField({
|
||||
@@ -25,18 +29,56 @@ export function EndpointField({
|
||||
showManageButton = true,
|
||||
onManageClick,
|
||||
manageButtonLabel,
|
||||
showFullUrlToggle = false,
|
||||
isFullUrl = false,
|
||||
onFullUrlChange,
|
||||
}: EndpointFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const defaultManageLabel = t("providerForm.manageAndTest", {
|
||||
defaultValue: "管理和测速",
|
||||
});
|
||||
const effectiveHint =
|
||||
showFullUrlToggle && isFullUrl
|
||||
? t("providerForm.fullUrlHint", {
|
||||
defaultValue:
|
||||
"💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
|
||||
})
|
||||
: hint;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel htmlFor={id}>{label}</FormLabel>
|
||||
{showManageButton && onManageClick && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FormLabel htmlFor={id}>{label}</FormLabel>
|
||||
{showFullUrlToggle && onFullUrlChange ? (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/70 bg-muted/30 px-2.5 py-1">
|
||||
<Link2
|
||||
className={`h-3.5 w-3.5 ${
|
||||
isFullUrl ? "text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isFullUrl ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("providerForm.fullUrlLabel", {
|
||||
defaultValue: "完整 URL",
|
||||
})}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isFullUrl}
|
||||
onCheckedChange={onFullUrlChange}
|
||||
aria-label={t("providerForm.fullUrlLabel", {
|
||||
defaultValue: "完整 URL",
|
||||
})}
|
||||
className="h-5 w-9"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showManageButton && onManageClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageClick}
|
||||
@@ -45,7 +87,7 @@ export function EndpointField({
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{manageButtonLabel || defaultManageLabel}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
@@ -55,9 +97,11 @@ export function EndpointField({
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{hint ? (
|
||||
{effectiveHint ? (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{effectiveHint}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,5 +11,4 @@ export const TEMPLATE_TYPES = {
|
||||
GITHUB_COPILOT: "github_copilot",
|
||||
} as const;
|
||||
|
||||
export type TemplateType =
|
||||
(typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
|
||||
export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES];
|
||||
|
||||
@@ -453,10 +453,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
websiteUrl: "https://www.kimi.com/coding/docs/",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "Kimi For Coding",
|
||||
options: {
|
||||
baseURL: "https://api.kimi.com/v1",
|
||||
baseURL: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
@@ -470,8 +470,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
templateValues: {
|
||||
baseURL: {
|
||||
label: "Base URL",
|
||||
placeholder: "https://api.kimi.com/v1",
|
||||
defaultValue: "https://api.kimi.com/v1",
|
||||
placeholder: "https://api.kimi.com/coding/v1",
|
||||
defaultValue: "https://api.kimi.com/coding/v1",
|
||||
editorValue: "",
|
||||
},
|
||||
apiKey: {
|
||||
@@ -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: {},
|
||||
|
||||
@@ -23,7 +23,7 @@ import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
* Extracts business logic from App.tsx
|
||||
*/
|
||||
export function useProviderActions(activeApp: AppId) {
|
||||
export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId) {
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
addToLive?: boolean;
|
||||
},
|
||||
) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
@@ -120,8 +121,8 @@ export function useProviderActions(activeApp: AppId) {
|
||||
|
||||
// 更新供应商
|
||||
const updateProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
await updateProviderMutation.mutateAsync(provider);
|
||||
async (provider: Provider, originalId?: string) => {
|
||||
await updateProviderMutation.mutateAsync({ provider, originalId });
|
||||
|
||||
// 更新托盘菜单(失败不影响主操作)
|
||||
try {
|
||||
@@ -139,6 +140,52 @@ export function useProviderActions(activeApp: AppId) {
|
||||
// 切换供应商
|
||||
const switchProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
const isCopilotProvider =
|
||||
activeApp === "claude" &&
|
||||
provider.meta?.providerType === "github_copilot";
|
||||
|
||||
// Determine why this provider requires the proxy
|
||||
let proxyRequiredReason: string | null = null;
|
||||
if (!isProxyRunning && provider.category !== "official") {
|
||||
if (isCopilotProvider) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
|
||||
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.apiFormat === "openai_chat" &&
|
||||
activeApp === "claude"
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
|
||||
defaultValue: "使用 OpenAI Chat 接口格式",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.apiFormat === "openai_responses" &&
|
||||
activeApp === "claude"
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
|
||||
defaultValue: "使用 OpenAI Responses 接口格式",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.isFullUrl &&
|
||||
(activeApp === "claude" || activeApp === "codex")
|
||||
) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
|
||||
defaultValue: "开启了完整 URL 连接模式",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyRequiredReason) {
|
||||
toast.warning(
|
||||
t("notifications.proxyRequiredForSwitch", {
|
||||
reason: proxyRequiredReason,
|
||||
defaultValue:
|
||||
"此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
@@ -158,15 +205,15 @@ export function useProviderActions(activeApp: AppId) {
|
||||
if (
|
||||
activeApp === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
(provider.meta?.apiFormat === "openai_chat" ||
|
||||
(isCopilotProvider ||
|
||||
provider.meta?.apiFormat === "openai_chat" ||
|
||||
provider.meta?.apiFormat === "openai_responses")
|
||||
) {
|
||||
// OpenAI format provider: show proxy hint
|
||||
toast.info(
|
||||
t("notifications.openAIFormatHint", {
|
||||
defaultValue:
|
||||
"此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
|
||||
}),
|
||||
isCopilotProvider
|
||||
? t("notifications.copilotProxyHint")
|
||||
: t("notifications.openAIFormatHint"),
|
||||
{
|
||||
duration: 5000,
|
||||
closeButton: true,
|
||||
@@ -192,7 +239,7 @@ export function useProviderActions(activeApp: AppId) {
|
||||
// 错误提示由 mutation 处理
|
||||
}
|
||||
},
|
||||
[switchProviderMutation, syncClaudePlugin, activeApp, t],
|
||||
[switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
|
||||
);
|
||||
|
||||
// 删除供应商
|
||||
|
||||
+10
-8
@@ -1,4 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
skillsApi,
|
||||
type SkillBackupEntry,
|
||||
@@ -108,13 +113,10 @@ export function useInstallSkill() {
|
||||
export function useUninstallSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
skillKey,
|
||||
}: {
|
||||
id: string;
|
||||
skillKey: string;
|
||||
}) => skillsApi.uninstallUnified(id).then((result) => ({ ...result, skillKey })),
|
||||
mutationFn: ({ id, skillKey }: { id: string; skillKey: string }) =>
|
||||
skillsApi
|
||||
.uninstallUnified(id)
|
||||
.then((result) => ({ ...result, skillKey })),
|
||||
onSuccess: ({ skillKey }, _vars) => {
|
||||
// 直接更新 installed 缓存,移除该 skill
|
||||
queryClient.setQueryData<InstalledSkill[]>(
|
||||
|
||||
@@ -174,8 +174,13 @@
|
||||
"deleteFailed": "Failed to delete provider: {{error}}",
|
||||
"settingsSaved": "Settings saved",
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
|
||||
"proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.",
|
||||
"proxyReasonCopilot": "uses GitHub Copilot as a Claude provider",
|
||||
"proxyReasonOpenAIChat": "uses OpenAI Chat API format",
|
||||
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
|
||||
"proxyReasonFullUrl": "has full URL connection mode enabled",
|
||||
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
|
||||
"copilotProxyHint": "GitHub Copilot as a Claude provider always requires the local proxy; the proxy automatically selects Chat Completions or Responses based on the current model.",
|
||||
"openLinkFailed": "Failed to open link",
|
||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||
"openclawDefaultModelSet": "Set as default model",
|
||||
@@ -608,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",
|
||||
@@ -636,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",
|
||||
@@ -693,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": {
|
||||
@@ -743,6 +764,10 @@
|
||||
"anthropicReasoningModel": "Reasoning Model (Thinking)",
|
||||
"apiFormat": "API Format",
|
||||
"apiFormatHint": "Select the input format for the provider's API",
|
||||
"fullUrlLabel": "Full URL",
|
||||
"fullUrlEnabled": "Full URL Mode",
|
||||
"fullUrlDisabled": "Mark as Full URL",
|
||||
"fullUrlHint": "💡 Enter the full request URL. This mode requires the proxy to be enabled, and the proxy will use the URL as-is without appending a path",
|
||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)",
|
||||
@@ -908,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.",
|
||||
@@ -1367,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.",
|
||||
|
||||
@@ -174,8 +174,13 @@
|
||||
"deleteFailed": "プロバイダーの削除に失敗しました: {{error}}",
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
|
||||
"proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており",
|
||||
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
|
||||
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
|
||||
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
|
||||
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。",
|
||||
"openLinkFailed": "リンクを開けませんでした",
|
||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||
@@ -608,6 +613,16 @@
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
"sessionList": "セッション一覧",
|
||||
"manageBatchTooltip": "一括管理に入る",
|
||||
"exitBatchModeTooltip": "一括管理を終了",
|
||||
"batchModeHint": "削除するセッションを選択",
|
||||
"selectForBatch": "セッションを選択",
|
||||
"selectedCount": "{{count}} 件を選択中",
|
||||
"selectAllFiltered": "一覧を全選択",
|
||||
"clearFilteredSelection": "全選択を解除",
|
||||
"clearSelection": "クリア",
|
||||
"deleteSelected": "削除",
|
||||
"batchDeleting": "削除中...",
|
||||
"loadingSessions": "セッションを読み込み中...",
|
||||
"noSessions": "セッションが見つかりません",
|
||||
"selectSession": "セッションを選択してください",
|
||||
@@ -636,6 +651,12 @@
|
||||
"deleteConfirmAction": "セッションを削除",
|
||||
"sessionDeleted": "セッションを削除しました",
|
||||
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
||||
"batchDeleteConfirmTitle": "選択したセッションを削除",
|
||||
"batchDeleteConfirmMessage": "選択した {{count}} 件のローカルセッションを完全に削除します。\n\nこの操作は元に戻せません。",
|
||||
"batchDeleteConfirmAction": "選択した項目を削除",
|
||||
"batchDeleteSuccess": "{{count}} 件のセッションを削除しました",
|
||||
"batchDeleteFailed": "{{failed}} 件のセッションを削除できませんでした",
|
||||
"batchDeleteRequestFailed": "一括削除に失敗しました。しばらくしてから再試行してください。",
|
||||
"loadingMessages": "内容を読み込み中...",
|
||||
"emptySession": "表示できる内容がありません",
|
||||
"clickToCopyPath": "クリックしてパスをコピー",
|
||||
@@ -693,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": {
|
||||
@@ -743,6 +764,10 @@
|
||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
||||
"apiFormat": "API フォーマット",
|
||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||
"fullUrlLabel": "フル URL",
|
||||
"fullUrlEnabled": "フル URL モード",
|
||||
"fullUrlDisabled": "フル URL として設定",
|
||||
"fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはプロキシを有効にして使用する必要があり、プロキシはこの URL をそのまま使用し、パスを追加しません",
|
||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)",
|
||||
@@ -908,7 +933,8 @@
|
||||
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。",
|
||||
@@ -1367,7 +1393,8 @@
|
||||
"backupCreated": "バックアップを作成しました: {{path}}",
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
|
||||
@@ -174,8 +174,13 @@
|
||||
"deleteFailed": "删除供应商失败:{{error}}",
|
||||
"settingsSaved": "设置已保存",
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
"proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
||||
"proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商",
|
||||
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
|
||||
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
|
||||
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
|
||||
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
|
||||
"copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。",
|
||||
"openLinkFailed": "链接打开失败",
|
||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||
"openclawDefaultModelSet": "已设为默认模型",
|
||||
@@ -608,6 +613,16 @@
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
"sessionList": "会话列表",
|
||||
"manageBatchTooltip": "进入批量管理",
|
||||
"exitBatchModeTooltip": "退出批量管理",
|
||||
"batchModeHint": "勾选要删除的会话",
|
||||
"selectForBatch": "选择会话",
|
||||
"selectedCount": "已选 {{count}} 项",
|
||||
"selectAllFiltered": "全选当前",
|
||||
"clearFilteredSelection": "取消全选",
|
||||
"clearSelection": "清空已选",
|
||||
"deleteSelected": "批量删除",
|
||||
"batchDeleting": "删除中...",
|
||||
"loadingSessions": "加载会话中...",
|
||||
"noSessions": "未发现会话",
|
||||
"selectSession": "请选择会话查看详情",
|
||||
@@ -636,6 +651,12 @@
|
||||
"deleteConfirmAction": "删除会话",
|
||||
"sessionDeleted": "会话已删除",
|
||||
"deleteFailed": "删除会话失败: {{error}}",
|
||||
"batchDeleteConfirmTitle": "批量删除会话",
|
||||
"batchDeleteConfirmMessage": "将永久删除已选中的 {{count}} 个本地会话记录。\n\n此操作不可恢复。",
|
||||
"batchDeleteConfirmAction": "删除所选会话",
|
||||
"batchDeleteSuccess": "已删除 {{count}} 个会话",
|
||||
"batchDeleteFailed": "{{failed}} 个会话删除失败",
|
||||
"batchDeleteRequestFailed": "批量删除失败,请稍后重试",
|
||||
"loadingMessages": "加载会话内容中...",
|
||||
"emptySession": "该会话暂无可展示内容",
|
||||
"clickToCopyPath": "点击复制路径",
|
||||
@@ -693,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": {
|
||||
@@ -743,6 +764,10 @@
|
||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||
"fullUrlLabel": "完整 URL",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "标记为完整 URL",
|
||||
"fullUrlHint": "💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
|
||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
|
||||
@@ -908,7 +933,8 @@
|
||||
"modelsRequired": "请至少添加一个模型配置",
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符",
|
||||
"providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
@@ -1367,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 () => {
|
||||
|
||||
@@ -163,6 +163,8 @@ export interface ProviderMeta {
|
||||
authBinding?: AuthBinding;
|
||||
// Claude 认证字段名
|
||||
apiKeyField?: ClaudeApiKeyField;
|
||||
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径)
|
||||
isFullUrl?: boolean;
|
||||
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)
|
||||
promptCacheKey?: string;
|
||||
// 供应商类型(用于识别 Copilot 等特殊供应商)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user