mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b989bd681 | |||
| 55abd1822c | |||
| 9171ad752c | |||
| 2db3163cf2 | |||
| 169d58ac6f | |||
| 2781d40e82 | |||
| 2d478876fa | |||
| 895d7af3eb | |||
| 92930461b7 | |||
| c797b2a3fb | |||
| c4630b5c26 | |||
| a3b3a06f5e | |||
| e648b7425e | |||
| c26d867f79 | |||
| 26be9324dd | |||
| 142c8c1da7 | |||
| 6ec86cff46 | |||
| 455556380b | |||
| 510aa250c5 | |||
| d1b5df9a7b | |||
| dfa03b746a | |||
| 3d30dc03e8 | |||
| 95495ad19e | |||
| b724f5dde9 | |||
| 69341db284 | |||
| 0bb3b7515a | |||
| 81d6002ace | |||
| caa912e3a3 | |||
| 1042fb2a32 | |||
| de0a149df5 | |||
| 36b557b2e6 | |||
| 3e38889ccc | |||
| 12567b3229 | |||
| c548e7fcba |
@@ -16,9 +16,12 @@ jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-2022
|
||||
- os: windows-11-arm
|
||||
arch: arm64
|
||||
- os: ubuntu-22.04
|
||||
- os: ubuntu-22.04-arm
|
||||
arch: arm64
|
||||
@@ -36,6 +39,11 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Add Windows ARM64 target
|
||||
if: runner.os == 'Windows' && matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: rustup target add aarch64-pc-windows-msvc
|
||||
|
||||
- name: Add macOS targets
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
@@ -74,23 +82,50 @@ jobs:
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
if: runner.os != 'Windows' || matrix.arch != 'arm64'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
|
||||
- name: Setup pnpm (Windows ARM64)
|
||||
if: runner.os == 'Windows' && matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.12.3 --activate
|
||||
node --version
|
||||
pnpm --version
|
||||
|
||||
- name: Get pnpm store directory
|
||||
if: runner.os != 'Windows' || matrix.arch != 'arm64'
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
if: runner.os != 'Windows' || matrix.arch != 'arm64'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
|
||||
|
||||
- name: Setup LLVM for Windows ARM64
|
||||
if: runner.os == 'Windows' && matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$llvmRoot = 'C:\Program Files\LLVM'
|
||||
if (-not (Test-Path $llvmRoot)) {
|
||||
throw "LLVM not found at $llvmRoot"
|
||||
}
|
||||
$llvmBin = Join-Path $llvmRoot 'bin'
|
||||
"LIBCLANG_PATH=$llvmBin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CLANG_PATH=$(Join-Path $llvmBin 'clang.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
$llvmBin | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -223,7 +258,16 @@ jobs:
|
||||
|
||||
- name: Build Tauri App (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: pnpm tauri build
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:WINDOWS_RELEASE_ARCH -eq 'arm64') {
|
||||
pnpm tauri build --target aarch64-pc-windows-msvc --bundles msi
|
||||
} else {
|
||||
pnpm tauri build
|
||||
}
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
@@ -394,50 +438,75 @@ jobs:
|
||||
- name: Prepare Windows Assets
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
|
||||
$isArm64 = $env:WINDOWS_RELEASE_ARCH -eq 'arm64'
|
||||
$targetRoot = if ($isArm64) { 'src-tauri/target/aarch64-pc-windows-msvc/release' } else { 'src-tauri/target/release' }
|
||||
$assetSuffix = if ($isArm64) { '-arm64' } else { '' }
|
||||
|
||||
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle/msi') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $msi) {
|
||||
# 兜底:全局搜索 .msi
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
}
|
||||
if ($null -ne $msi) {
|
||||
$dest = "CC-Switch-$VERSION-Windows.msi"
|
||||
$dest = "CC-Switch-$VERSION-Windows$assetSuffix.msi"
|
||||
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
||||
Write-Host "Installer copied: $dest"
|
||||
$sigPath = "$($msi.FullName).sig"
|
||||
if (Test-Path $sigPath) {
|
||||
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
|
||||
Write-Host "Signature copied: $dest.sig"
|
||||
} elseif ($isArm64) {
|
||||
throw "Signature not found for $($msi.Name)"
|
||||
} else {
|
||||
Write-Warning "Signature not found for $($msi.Name)"
|
||||
}
|
||||
} elseif ($isArm64) {
|
||||
throw 'No Windows ARM64 MSI installer found'
|
||||
} else {
|
||||
Write-Warning 'No Windows MSI installer found'
|
||||
}
|
||||
|
||||
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
|
||||
$exeCandidates = @(
|
||||
'src-tauri/target/release/cc-switch.exe',
|
||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||
)
|
||||
$exeCandidates = if ($isArm64) {
|
||||
@('src-tauri/target/aarch64-pc-windows-msvc/release/cc-switch.exe')
|
||||
} else {
|
||||
@(
|
||||
'src-tauri/target/release/cc-switch.exe',
|
||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||
)
|
||||
}
|
||||
$exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ($null -ne $exePath) {
|
||||
$portableDir = 'release-assets/CC-Switch-Portable'
|
||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||
Copy-Item $exePath $portableDir
|
||||
$portableIniPath = Join-Path $portableDir 'portable.ini'
|
||||
$portableContent = @(
|
||||
'# CC Switch portable build marker',
|
||||
'portable=true'
|
||||
)
|
||||
$portableContent = if ($isArm64) {
|
||||
@(
|
||||
'# CC Switch portable ARM64 build marker',
|
||||
'portable=true',
|
||||
'arch=arm64'
|
||||
)
|
||||
} else {
|
||||
@(
|
||||
'# CC Switch portable build marker',
|
||||
'portable=true'
|
||||
)
|
||||
}
|
||||
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
||||
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
|
||||
$portableZip = "release-assets/CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip"
|
||||
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
|
||||
Remove-Item -Recurse -Force $portableDir
|
||||
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
|
||||
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip"
|
||||
} elseif ($isArm64) {
|
||||
throw 'Portable ARM64 exe not found'
|
||||
} else {
|
||||
Write-Warning 'Portable exe not found'
|
||||
}
|
||||
@@ -550,7 +619,8 @@ jobs:
|
||||
### 下载
|
||||
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.dmg`(推荐)或 `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)
|
||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||
- **Windows (x86_64)**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||
- **Windows (ARM64)**: `CC-Switch-${{ github.ref_name }}-Windows-arm64.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-arm64-Portable.zip`(绿色版)
|
||||
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
@@ -594,7 +664,8 @@ jobs:
|
||||
base_url="https://github.com/$REPO/releases/download/$TAG"
|
||||
# 初始化空平台映射
|
||||
mac_url=""; mac_sig=""
|
||||
win_url=""; win_sig=""
|
||||
win_x64_url=""; win_x64_sig=""
|
||||
win_arm64_url=""; win_arm64_sig=""
|
||||
linux_x64_url=""; linux_x64_sig=""
|
||||
linux_arm64_url=""; linux_arm64_sig=""
|
||||
shopt -s nullglob
|
||||
@@ -607,12 +678,14 @@ jobs:
|
||||
*.tar.gz)
|
||||
# 视为 macOS updater artifact
|
||||
mac_url="$url"; mac_sig="$sig_content";;
|
||||
*-Windows-arm64.msi)
|
||||
win_arm64_url="$url"; win_arm64_sig="$sig_content";;
|
||||
*-Windows.msi)
|
||||
win_x64_url="$url"; win_x64_sig="$sig_content";;
|
||||
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
|
||||
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
|
||||
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
|
||||
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
|
||||
*.msi|*.exe)
|
||||
win_url="$url"; win_sig="$sig_content";;
|
||||
esac
|
||||
done
|
||||
# 构造 JSON(仅包含存在的目标)
|
||||
@@ -632,9 +705,14 @@ jobs:
|
||||
first=0
|
||||
done
|
||||
fi
|
||||
if [ -n "$win_url" ] && [ -n "$win_sig" ]; then
|
||||
if [ -n "$win_x64_url" ] && [ -n "$win_x64_sig" ]; then
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
||||
echo " \"windows-x86_64\": {\"signature\": \"$win_x64_sig\", \"url\": \"$win_x64_url\"}"
|
||||
first=0
|
||||
fi
|
||||
if [ -n "$win_arm64_url" ] && [ -n "$win_arm64_sig" ]; then
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"windows-aarch64\": {\"signature\": \"$win_arm64_sig\", \"url\": \"$win_arm64_url\"}"
|
||||
first=0
|
||||
fi
|
||||
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
|
||||
|
||||
@@ -24,11 +24,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Deutsch](README_
|
||||
<details open>
|
||||
<summary>Click to collapse</summary>
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
|
||||
MiniMax-M2.7 is a next-generation large language model designed for autonomous evolution and real-world productivity. Unlike traditional models, M2.7 actively participates in its own improvement through agent teams, dynamic tool use, and reinforcement learning loops. It delivers strong performance in software engineering (56.22% on SWE-Pro, 55.6% on VIBE-Pro, 57.0% on Terminal Bench 2) and excels in complex office workflows, achieving a leading 1495 ELO on GDPval-AA. With high-fidelity editing across Word, Excel, and PowerPoint, and a 97% adherence rate across 40+ complex skills, M2.7 sets a new standard for building AI-native workflows and organizations.
|
||||
|
||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Token Plan!
|
||||
Kimi K2.6 is an open-source, native multimodal agentic model from Moonshot AI, built for long-horizon coding, coding-driven design, and swarm-based task orchestration. It is designed to handle complex end-to-end engineering work across front-end, DevOps, performance optimization, and full-stack workflows, while coordinating large groups of specialized agents to plan, implement, test, and iterate on real coding tasks. **[Click here to start using Kimi](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
|
||||
---
|
||||
|
||||
@@ -63,7 +61,7 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -107,8 +105,8 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
||||
<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>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>Thanks to ETok.ai for sponsoring this project! ETok.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://etok.ai">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -142,7 +140,7 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">this link</a> and get $5 free credit on sign-up.</td>
|
||||
</tr>
|
||||
|
||||
|
||||
+6
-8
@@ -24,11 +24,9 @@
|
||||
<details open>
|
||||
<summary>Zum Einklappen klicken</summary>
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
|
||||
MiniMax-M2.7 ist ein großes Sprachmodell der nächsten Generation, das auf autonome Weiterentwicklung und praxisnahe Produktivität ausgelegt ist. Anders als herkömmliche Modelle beteiligt sich M2.7 aktiv an seiner eigenen Verbesserung — durch Agententeams, dynamische Werkzeugnutzung und Reinforcement-Learning-Schleifen. Es liefert starke Leistung im Software-Engineering (56,22 % bei SWE-Pro, 55,6 % bei VIBE-Pro, 57,0 % bei Terminal Bench 2) und überzeugt bei komplexen Büro-Workflows mit einem führenden Wert von 1495 ELO bei GDPval-AA. Mit originalgetreuer Bearbeitung von Word-, Excel- und PowerPoint-Dateien sowie einer Befolgungsrate von 97 % über 40+ komplexe Skills hinweg setzt M2.7 einen neuen Standard für den Aufbau KI-nativer Workflows und Organisationen.
|
||||
|
||||
[Klicken Sie hier](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link), um exklusive 12 % Rabatt auf den MiniMax Token Plan zu erhalten!
|
||||
Kimi K2.6 ist ein quelloffenes, nativ multimodales Agenten-Modell von Moonshot AI, das für langfristige Programmierung, code-getriebenes Design und schwarmbasierte Aufgabenorchestrierung entwickelt wurde. Es ist darauf ausgelegt, komplexe End-to-End-Engineering-Arbeiten über Frontend, DevOps, Performance-Optimierung und Full-Stack-Workflows hinweg zu bewältigen und dabei große Gruppen spezialisierter Agenten zu koordinieren, um echte Programmieraufgaben zu planen, umzusetzen, zu testen und zu iterieren. **[Hier klicken, um Kimi auszuprobieren](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
|
||||
---
|
||||
|
||||
@@ -63,7 +61,7 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -107,8 +105,8 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
||||
<td>Danke an CTok.ai für die Unterstützung dieses Projekts! CTok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://ctok.ai">hier</a>, um sich zu registrieren!</td>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>Danke an ETok.ai für die Unterstützung dieses Projekts! ETok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://etok.ai">hier</a>, um sich zu registrieren!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -142,7 +140,7 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">diesen Link</a> und erhalten Sie $5 Startguthaben bei der Anmeldung.</td>
|
||||
</tr>
|
||||
|
||||
|
||||
+6
-8
@@ -24,11 +24,9 @@
|
||||
<details open>
|
||||
<summary>クリックで折りたたむ</summary>
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
|
||||
MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設計された次世代大規模言語モデルです。従来のモデルとは異なり、M2.7 はエージェントチーム、動的ツール使用、強化学習ループを通じて自身の改善に積極的に参加します。ソフトウェアエンジニアリングにおいて優れた性能を発揮し(SWE-Pro で 56.22%、VIBE-Pro で 55.6%、Terminal Bench 2 で 57.0%)、複雑なオフィスワークフローにも秀でており、GDPval-AA で 1495 ELO のリーディングスコアを達成しています。Word・Excel・PowerPoint の高忠実度編集と、40 以上の複雑なスキルにわたる 97% の遵守率により、M2.7 は AI ネイティブなワークフローと組織構築の新基準を打ち立てます。
|
||||
|
||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手!
|
||||
Kimi K2.6 は Moonshot AI によるオープンソースのネイティブマルチモーダル・エージェントモデルで、長期的なコーディング、コード駆動設計、スウォーム(群)型タスクオーケストレーションのために構築されています。フロントエンド、DevOps、パフォーマンス最適化、フルスタックワークフローにわたる複雑なエンドツーエンドのエンジニアリング作業に対応できるよう設計されており、多数の専門エージェントを協調させて、実際のコーディングタスクの計画・実装・テスト・反復を行います。**[ここをクリックして Kimi を体験する](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
|
||||
---
|
||||
|
||||
@@ -63,7 +61,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -107,8 +105,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
||||
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>ETok.ai のご支援に感謝します!ETok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://etok.ai">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -142,7 +140,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">こちらのリンク</a>から登録すると $5 の無料クレジットがもらえます。</td>
|
||||
</tr>
|
||||
|
||||
|
||||
+7
-9
@@ -24,11 +24,9 @@
|
||||
<details open>
|
||||
<summary>点击折叠</summary>
|
||||
|
||||
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
||||
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||
|
||||
MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力。
|
||||
|
||||
[点击此处](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)享 MiniMax Token Plan 专属 88 折优惠!
|
||||
Kimi K2.6 是 Moonshot AI 开源的原生多模态智能体模型,面向长程编码、代码驱动设计和智能体集群任务编排而构建。它适合处理复杂的端到端工程任务,覆盖前端开发、DevOps、性能优化和全栈工作流,并能够协调大规模专业智能体,对真实编码任务进行规划、实现、测试和迭代。**[点击此处开启 Kimi 使用体验](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||
|
||||
---
|
||||
|
||||
@@ -62,8 +60,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -108,8 +106,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -143,7 +141,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||
<td>感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">此链接</a>注册即送 $5 体验额度!</td>
|
||||
</tr>
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 511 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,467 @@
|
||||
# Unified Codex Session History: Feature Overview and Usage Guide (CC Switch)
|
||||
|
||||
> Applies to CC Switch v3.16.x and later. This guide is based on the current code; every command and path can be verified by hand. Examples use de-identified data and contain no real session content or API keys.
|
||||
|
||||
## What this feature is
|
||||
|
||||
"Unified Codex session history" is a switch that CC Switch v3.16.x adds for Codex. You'll find it under **Settings -> General -> the "Codex App Enhancements" group** ("Codex App Enhancements" is the group title; the switch itself is called "Unified Codex session history"). Once enabled, **sessions from your official subscription (ChatGPT login / OpenAI API key) appear in the same history / resume list as sessions from every third-party provider CC Switch manages**—they are no longer split into two lists that can't see each other.
|
||||
|
||||
## What problem it solves
|
||||
|
||||
Codex classifies sessions by a "provider tag" (a field called `model_provider`), and **the resume / history list only shows sessions whose tag matches your currently active provider**. As a result, sessions are naturally sorted into two separate "drawers":
|
||||
|
||||
- Sessions from your official subscription go under Codex's built-in **`openai`** tag;
|
||||
- Every third-party provider CC Switch manages goes under the **`custom`** tag.
|
||||
|
||||
The two drawers can't see each other. If you **switch frequently between official and third-party**, you'll hit this kind of fragmentation: "the session I was just chatting in with the official account disappeared from the history list after I switched to a third-party provider"—it isn't actually gone, it's just been sorted into the other drawer. This split both makes it easy to believe a session was lost, and makes it inconvenient to review and resume all your sessions in one place.
|
||||
|
||||
**This switch exists to eliminate that fragmentation**: it makes the official subscription run under the `custom` tag too, so official and third-party sessions merge into one list and everything is easy to find and resume in a single place.
|
||||
|
||||
> ✅ **One important premise that runs through this whole guide, please remember it first**: this feature (unify / migrate / restore) **only ever rewrites that one classification tag `model_provider` in your session records, and it automatically makes a backup of the original file before every rewrite**. It never deletes, clears, or overwrites a single line of your conversations. So whenever this guide later mentions "some sessions are no longer visible," it almost always means "they've been sorted into the other drawer," not "the data is gone." If you're truly worried, jump straight to the [symptom reference table](#i-feel-like-my-sessions-are-gone-symptom-reference-table) and [verify the files are still there by hand](#verify-by-hand-your-session-files-are-still-on-disk-the-most-important-section).
|
||||
|
||||
## How it works (one-line version)
|
||||
|
||||
Think of it as **two drawers + automatic backup**:
|
||||
|
||||
- By default, official sessions live in the `openai` drawer and third-party sessions live in the `custom` drawer, invisible to each other;
|
||||
- The switch makes **the official side use the `custom` drawer too**, merging the two drawers into one shared list;
|
||||
- You can optionally choose to "move" your **existing official sessions** into the shared drawer as well (this step is called **migration**; it's optional and requires you to opt in by checking a box), and **before anything is moved a backup copy is made first**, so the whole process is **reversible**;
|
||||
- **Authentication is completely unaffected**—your official subscription still uses your ChatGPT login and still goes through the official backend; only the session's classification tag changes.
|
||||
|
||||
For the full mechanism (what gets injected, why it's reversible, how migration / restore guarantee no data loss) see [The core mental model](#the-core-mental-model-two-drawers--automatic-backup) and the [Advanced mechanism appendix](#advanced-mechanism-appendix-for-users-who-want-to-truly-understand-how-it-works) at the end.
|
||||
|
||||
## How to use it (at a glance)
|
||||
|
||||
1. **Enable**: Settings -> General -> Codex App Enhancements -> turn on "Unified Codex session history" -> in the dialog decide whether to check "Also migrate existing official session history" (check it if you want your **earlier** official sessions merged into the unified list too; leave it unchecked if you only want unification from now on) -> confirm. See [What happens when you enable it](#what-happens-when-you-enable-it-step-by-step).
|
||||
2. **Disable**: turn the same switch off -> in the dialog keep "restore exactly from backup" checked (it's checked by default) -> confirm, and the official sessions you migrated in will be precisely flipped back to the official list. See [What happens when you disable it](#what-happens-when-you-disable-it-step-by-step).
|
||||
3. **Feel like a session is gone?** Don't panic—jump to the [symptom reference table](#i-feel-like-my-sessions-are-gone-symptom-reference-table) to locate it by symptom, and use the commands in the [verify by hand](#verify-by-hand-your-session-files-are-still-on-disk-the-most-important-section) section to see for yourself that the files are all there.
|
||||
|
||||
---
|
||||
|
||||
## The core mental model: two drawers + automatic backup
|
||||
|
||||
To understand this feature, you only need to remember two things: **drawers** and **backups**.
|
||||
|
||||
### Drawers: how Codex classifies sessions
|
||||
|
||||
Every time you start a Codex session, Codex records a tag `model_provider` in the session file header, marking "which provider this session was chatted with." Codex's **resume / history list is filtered precisely by the currently active tag**—it only shows sessions whose tag matches "the provider you're on right now."
|
||||
|
||||
- Sessions from your official subscription (ChatGPT login / OpenAI API key) carry the built-in tag **`openai`**.
|
||||
- Every third-party provider CC Switch manages uses the tag **`custom`**.
|
||||
|
||||
So by default, official sessions and third-party sessions are inherently invisible to each other—they live in two different drawers. This is **Codex's own design**, not CC Switch losing anything.
|
||||
|
||||
```text
|
||||
Default state (unified switch off):
|
||||
┌───────────────────────┐ ┌──────────────────────────┐
|
||||
│ openai drawer │ │ custom drawer │
|
||||
│ (official sessions) │ │ (third-party sessions) │
|
||||
└───────────────────────┘ └──────────────────────────┘
|
||||
▲ ▲
|
||||
visible only while visible only while
|
||||
on the official provider on a third-party provider
|
||||
|
||||
The two drawers can't see each other.
|
||||
```
|
||||
|
||||
**What the "Unified Codex session history" switch does is make the official subscription run under the `custom` tag too, merging the two drawers into one**, so official and third-party sessions appear in the same resume list. Note: **authentication doesn't change**—your official subscription still uses your ChatGPT login and still goes through the official backend; only the session's "classification tag" changes from `openai` to `custom`.
|
||||
|
||||
```text
|
||||
After the unified switch is on:
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ custom shared drawer │
|
||||
│ official sessions + third-party sessions │
|
||||
│ (appear in the same history / resume list) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Backups: a copy is made before every tag change
|
||||
|
||||
"Merging the drawers" requires changing the tag of some official sessions from `openai` to `custom` (this step is called **migration**, and it's **optional and requires you to opt in**). And **before any rewrite, CC Switch first copies the original file untouched** to here:
|
||||
|
||||
```text
|
||||
~/.cc-switch/backups/codex-official-history-unify-v1/<timestamp>/
|
||||
```
|
||||
|
||||
This backup is the sole basis for "restore exactly from backup" later. It makes the whole process **reversible**: at any time you can turn off the switch and precisely flip the official sessions you migrated in back to the `openai` drawer.
|
||||
|
||||
Remember these two words—**drawer** (a session just gets reclassified) and **backup** (a copy is always made before a change)—and everything that follows will be easy to understand.
|
||||
|
||||
---
|
||||
|
||||
## What happens when you enable it: step by step
|
||||
|
||||
### Step 1: Find the switch
|
||||
|
||||
```text
|
||||
Settings -> General -> Codex App Enhancements
|
||||
```
|
||||
|
||||
In the "Codex App Enhancements" block there are two rows of switches; the **second row** (the blue history icon) is the subject of this guide:
|
||||
|
||||
> **Unified Codex session history**
|
||||
|
||||
Below it is a line of description text (verbatim):
|
||||
|
||||
> When enabled, the official subscription runs under the shared "custom" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.
|
||||
|
||||
> **Note**: this single line of description already previews three things—sessions will appear in one list, you can optionally migrate them in with an automatic backup, and resuming across providers "may fail." Here, "fail" means **you can't resume / can't generate a new turn**, not "the record is lost." This is exactly the core misunderstanding we'll dig into below.
|
||||
|
||||
### Step 2: Flip the switch from off to on -> a confirmation dialog pops up
|
||||
|
||||
The moment you flip the switch on, CC Switch **does not save immediately**; instead it first pops up a confirmation dialog. The dialog text reads as follows (verbatim):
|
||||
|
||||
- **Title**: Unified Codex session history
|
||||
- **Body**:
|
||||
|
||||
> When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.
|
||||
>
|
||||
> You can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).
|
||||
|
||||
- **Checkbox**: Also migrate existing official session history
|
||||
- **Confirm button**: I understand, enable
|
||||
- **Cancel button**: Cancel
|
||||
|
||||
**This checkbox is unchecked by default.** This is an important fork in the road:
|
||||
|
||||
| Your choice | Effect | Where your data is right now |
|
||||
|---|---|---|
|
||||
| **Unchecked** (default) | Only switches the tag. **Only official sessions created after enabling** land in the `custom` shared drawer | Your official sessions from **before** enabling keep the `openai` tag, stay exactly where they were, still in `~/.codex/sessions/` |
|
||||
| **Checked** | In addition to switching the tag, also migrates your **existing official sessions** from the `openai` drawer into the `custom` drawer | After being **copied to backup**, the old sessions' tag is rewritten to `custom`; the original data is covered by the backup |
|
||||
|
||||
> **If you want "my earlier official sessions to appear in the unified list too," you must opt in by checking this box.** Otherwise you'll run into "scenario A" in the reference table below—the old sessions look "gone," when in fact they're just sitting in the original drawer.
|
||||
|
||||
Click "Cancel" or click outside the dialog: the switch flips straight back to off and nothing happens.
|
||||
Click "I understand, enable": the switch is saved as on, and CC Switch persists the configuration in the background (and runs the migration if you checked it).
|
||||
|
||||
### Step 3 (only if you checked migration): how migration runs + data safety
|
||||
|
||||
If you check "Also migrate existing official session history," CC Switch runs this procedure on your existing official sessions:
|
||||
|
||||
```text
|
||||
For each official (openai tag) session file:
|
||||
① First copy the original file untouched into the backup directory <- data now has its first safety net
|
||||
② Using "write a temp file -> replace the whole thing" atomic style,
|
||||
change only the model_provider in the session_meta line at the header
|
||||
from "openai" to "custom" <- not a single byte of the conversation body is touched
|
||||
③ Update the index database state_5.sqlite to switch the tag in the same transaction
|
||||
```
|
||||
|
||||
- **Backup location**: `~/.cc-switch/backups/codex-official-history-unify-v1/<timestamp>/`. Each migration produces one timestamped "generation directory," containing `jsonl/` (session copies), `state/` (index DB copy), and `meta.json` (recording which Codex directory this migration belongs to).
|
||||
- **What's changed**: only the value of the single field `model_provider`. Your conversation content, reasoning content, and all body text are **kept exactly as is**.
|
||||
- **What's deleted**: **nothing**. The backup is a "copy," the rewrite is an "atomic replacement of the same file," and at no point is any session or index deleted. The file is complete at every moment (either the old content or the new content, never empty or half-written).
|
||||
|
||||
After a successful migration, these existing official sessions show up in the unified list. **At this moment your data is**: ① the original copy in the backup directory; ② in the active file, only the classification tag changed, the content intact.
|
||||
|
||||
> **Note**: enabling and migration themselves **do not pop a success toast**. Migration runs as a side task on the backend during save; in the UI you'll only see the switch turn on. So "I didn't see a migration-success popup" is normal and does not mean failure.
|
||||
|
||||
---
|
||||
|
||||
## What happens when you disable it: step by step
|
||||
|
||||
### Step 1: Flip the switch from on to off -> probe for backups -> a confirmation dialog pops up
|
||||
|
||||
When disabling, CC Switch **first spends a moment probing whether there's a migration backup**, then pops up a confirmation dialog (so the disable dialog has a slight delay, which is normal). The text reads as follows (verbatim):
|
||||
|
||||
- **Title**: Turn off unified session history
|
||||
- **Body**:
|
||||
|
||||
> After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.
|
||||
|
||||
- **Checkbox** (shown conditionally): Restore the official sessions migrated at enable time back to the official history (exact restore from backup)
|
||||
- **Confirm button**: Turn off
|
||||
- **Cancel button**: Cancel
|
||||
|
||||
> **Key point**: the body says the official subscription **will not see them**—**won't see**, not **delete**. The new sessions you chatted during the unified period are still fully present in the `custom` drawer; after disabling, the official side simply won't see them.
|
||||
|
||||
**This restore checkbox is checked by default.** In other words, the default behavior is "restore the official sessions you migrated in back to the official history at the same time you disable." You only need to keep it checked and click "Turn off."
|
||||
|
||||
If the checkbox **doesn't appear**, the system has determined there's no backup that needs restoring (either you never checked migration, or no backup was found)—in that case your existing official sessions were never touched, and turning off the switch returns them to the `openai` drawer on their own.
|
||||
|
||||
### Step 2: How restore runs (precise flip-back per the backup ledger)
|
||||
|
||||
If you keep the box checked and click "Turn off," CC Switch's restore flow goes like this:
|
||||
|
||||
```text
|
||||
① First copy the current state once more into a separate restore-backup directory
|
||||
~/.cc-switch/backups/codex-official-history-unify-restore-v1/<timestamp>/
|
||||
(restore itself backs up first, so restore won't lose data either)
|
||||
② Comb through all migration backup generations, find the session ids "whose tag was originally openai," and assemble a "ledger"
|
||||
③ Only for sessions that are [both in the ledger AND currently still custom], change the tag back to "openai"
|
||||
```
|
||||
|
||||
Note the **dual condition** in step ③—it must be in the ledger (proving it really was migrated from the official side) AND currently still `custom` (showing you haven't manually changed it). Only when both conditions hold does it get flipped back. This guarantees the restore is both precise and free of collateral damage.
|
||||
|
||||
**At this moment your data is**: the migrated-back official sessions have their tag changed back to `openai` and reappear in the official list; meanwhile both the migration backup and the restore backup copies are still on disk.
|
||||
|
||||
### Step 3: Read the toast, confirm the result
|
||||
|
||||
Only the "disable + check restore" path pops a result toast. The toasts you may see (verbatim):
|
||||
|
||||
| Toast you see | Meaning |
|
||||
|---|---|
|
||||
| **Official session history restored from backup ({{files}} session files, {{rows}} index rows)** | Restore succeeded. `{{files}}` / `{{rows}}` show the actual numbers |
|
||||
| **No restorable migration backup for the current Codex directory** | Nothing to restore (**does not mean data is lost**, see scenario E in the reference table) |
|
||||
| **Unified session history was re-enabled; restore skipped** | You turned the switch back on while restore was queued, so the system deliberately abandoned the restore (see scenario F) |
|
||||
| **Failed to restore official session history, please try again** | The restore process errored; just retry, the data is not corrupted |
|
||||
| **Save failed, please try again** | The disable save itself failed; in this case **restore is never triggered** and the switch flips back to its original position |
|
||||
|
||||
> **A thoughtful safety design**: if the "disable the switch" save fails, CC Switch **never runs the restore**. Otherwise you'd end up in a torn state of "switch still on, but sessions flipped back to the openai bucket." When the save fails, the switch **automatically flips back to its original position**, so you won't be stuck in a fake state of "looks off but didn't actually save."
|
||||
|
||||
---
|
||||
|
||||
## "I feel like my sessions are gone?" symptom reference table
|
||||
|
||||
The six scenarios below are the situations where users most easily believe "sessions are gone." **The truth in every one is: the data is intact, it just moved drawers or is temporarily out of sight.** Use this table to locate your symptom first, then read the detailed explanation below.
|
||||
|
||||
| Scenario | What you see | The data truth | One-line fix |
|
||||
|---|---|---|---|
|
||||
| **A** Didn't check migration | Old official sessions not in the unified list | All present, still carry the `openai` tag | Re-enable and check migration, or turn off the switch |
|
||||
| **B** Cross-provider resume fails | Can't resume / errors out | Files intact, the ciphertext just can't be decrypted across backends | Resume on the original provider; to only read content, read the jsonl directly |
|
||||
| **C** Proxy takeover / injection refused | No migration and no restore | Migration was safely skipped, files untouched | Exit takeover -> restart and retry; or just turn off the switch |
|
||||
| **D** New sessions didn't return to official after restore | New sessions from the unified period aren't on the official side | They're in the `custom` drawer, untouched by design | Switch to a third-party provider to see them |
|
||||
| **E** Toast "no restorable backup" | Restore "failed" | Usually nothing was ever migrated, sessions are in the original drawer | Turn off the switch and the official sessions reappear automatically |
|
||||
| **F** Toast "switch was re-enabled, restore skipped" | Restore refused | Prevents a torn data state, nothing was changed | Fully turn off the switch first, then restore |
|
||||
|
||||
### Scenario A: You enabled the switch but didn't check migration -> old official sessions "disappear"
|
||||
|
||||
**Symptom**: you turned on the unified switch, but didn't check "Also migrate existing official session history" in the enable dialog (it's unchecked by default). After enabling, your earlier official sessions seem to be gone from the list.
|
||||
|
||||
**The truth**: 100% of your data is present, not a single line moved. The switch only takes effect on official sessions "created after enabling"; your official sessions from **before** enabling still carry the `openai` tag and sit untouched in `~/.codex/sessions/`. You're now on the `custom` drawer, so naturally you can't see the old sessions left in the `openai` drawer—that's the entire reason for the "apparent disappearance."
|
||||
|
||||
**What to do** (pick either):
|
||||
1. **Re-enable the switch and check "Also migrate existing official session history,"** which moves the old sessions to the `custom` drawer and they immediately appear in the unified list (automatic backup before the rewrite).
|
||||
2. **Or simply turn off the unified switch**, the official side runs on the `openai` drawer again, and the old sessions reappear right where they were.
|
||||
|
||||
### Scenario B: Cross-provider resume of an old session fails -> you think "this session is broken / gone"
|
||||
|
||||
**Symptom**: after unification, the list shows an old session chatted with "another provider." You switch to your current provider and click "Resume," but it errors out or can't connect.
|
||||
|
||||
**The truth**: the session file is intact; what's lost is not data, it's "cross-backend decryption ability." A Codex session stores an encrypted block of reasoning content `encrypted_content`, and **this ciphertext can only be decrypted by the backend that originally generated it**. Using provider B to resume a session generated by provider A means B can't decrypt A's ciphertext -> resume fails. This is **a design limitation of upstream Codex (by design)** and has nothing to do with whether CC Switch touched the file. The text content of the session is readable at any time.
|
||||
|
||||
> This is the **only "looks like a real problem" genuine exception** in this whole guide—but note: it just means **you can't resume (can't generate a new turn)**, and **the original file is still fully present**, the conversation text readable at any time.
|
||||
|
||||
**What to do**:
|
||||
- **Resume with "the provider that originally created this session,"** so it can decrypt normally and connect.
|
||||
- Just want to read the history without continuing? Read that session's `.jsonl` file directly (commands at the end).
|
||||
- Rule of thumb: **cross-provider is better suited to "starting a new session"; resume old sessions on their original provider whenever possible.**
|
||||
|
||||
### Scenario C: You enabled the switch and checked migration, but migration was silently skipped -> you think "migration lost the sessions"
|
||||
|
||||
**Symptom**: you enabled the switch and checked migration, but the old official sessions neither entered the unified list nor could be restored when you turned the switch off (or the restore checkbox didn't even appear in the disable dialog, see scenario E). You suspect migration lost the sessions during the process.
|
||||
|
||||
**The truth**: migration **never ran**, so it couldn't have lost anything—not a single character of your sessions was changed. CC Switch has a safety gate before migration: it checks whether Codex's live config (`~/.codex/config.toml`) is **actually** routed to the shared `custom` drawer right now, and only migrates if the routing truly went there. The following two situations are judged "not yet unified" (internal reason code `live_not_unified`), so CC Switch **deliberately skips the migration, preserves your switch and migration intent, and migrates later once the conditions are met**:
|
||||
|
||||
- **During proxy takeover**: CC Switch's proxy has taken over the live config, and the live config during takeover doesn't carry the unified routing marker.
|
||||
- **Injection refused**: your `config.toml` already has a manually specified `model_provider`, or there's already a differently-shaped `[model_providers.custom]` table (possibly with a third-party address). To avoid incorrectly routing official traffic to a third-party backend, CC Switch would rather not inject and not migrate.
|
||||
|
||||
Skipping migration = touching no session files. **No migration means nothing moved, so there's nothing to lose.** This is "safe deferral," not "failure with data loss."
|
||||
|
||||
**What to do**:
|
||||
- Exit proxy takeover -> **restart CC Switch**: on startup it automatically retries migration (your migration intent is preserved the whole time).
|
||||
- Check `~/.codex/config.toml`: if there's a conflicting route you wrote by hand, clean up the conflict before enabling the switch.
|
||||
- If you'd rather not bother: just turn off the switch, the official sessions still display normally on the `openai` drawer, completely intact.
|
||||
|
||||
### Scenario D: You turned off the switch and restored, but "the new sessions chatted during the unified period" didn't return to official -> you think "the new sessions are gone"
|
||||
|
||||
**Symptom**: during the unified period, you chatted a few more new sessions with the official account. Later you turned off the switch, checked restore, and after restoring you find those new sessions didn't return to the official drawer.
|
||||
|
||||
**The truth**: this is **intentional** design; the new sessions are perfectly fine in the `custom` drawer, visible and resumable. Restore is based on "the backup ledger from migration time"—**only sessions that were originally migrated in from the `openai` drawer** are recorded in the backup and get precisely flipped back to `openai`. The sessions you **created during the unified period** are in no backup ledger; and after unification both official and third-party use the `custom` tag, so **CC Switch can't tell whether a new session was chatted with the official account or a third-party**. To avoid wrongly stuffing third-party sessions into the official history, the product decision is: these new sessions all stay in the `custom` (third-party) history and are never moved automatically. The disable dialog's text says this explicitly too—"Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history."
|
||||
|
||||
**What to do**:
|
||||
- Switch to any third-party provider (the `custom` drawer) to see these sessions in the history list.
|
||||
- To read content, read the `.jsonl` directly; to resume, follow scenario B's rule (go back to the backend that originally generated it).
|
||||
- If you really want to manually return **one specific** session to official: there's currently no automatic button (deliberately omitted, to avoid misjudging the direction). Advanced users can, **after backing up** that file first, manually change `model_provider` in the `session_meta` of the first line of its `.jsonl` from `custom` back to `openai` (an advanced operation; always make a copy before editing).
|
||||
|
||||
### Scenario E: Restore toast "No restorable migration backup for the current Codex directory" -> you think "restore failed = data is gone"
|
||||
|
||||
**Symptom**: you checked restore when turning off the switch, and got the toast "No restorable migration backup for the current Codex directory." You panic: restore failed, is the data completely gone?
|
||||
|
||||
**The truth**: "nothing to restore" ≠ "data is lost." On the contrary, it's usually because **there was no migration that needed restoring**. Common reasons:
|
||||
|
||||
- **You never checked "migrate existing official sessions" in the first place**: with no migration, there's naturally no migration backup and no sessions to flip back. Your old official sessions have been in the `openai` drawer all along and reappear after you turn off the switch (same as scenario A). (In this case, the disable dialog may **not even show the restore checkbox**—because the system can't find any backup.)
|
||||
- **You've already restored once**: the session tags have all been flipped back to `openai`, so clicking again naturally finds "no targets still in custom to restore"—this is **idempotent protection, not failure**.
|
||||
- **You switched Codex directories**: restore only recognizes the backup ledger belonging to the **current** directory; switch directories and it can't find the old directory's ledger. Just switch the directory back.
|
||||
|
||||
In all three cases, no session was deleted.
|
||||
|
||||
**What to do**: use the end-of-guide commands to count the total session files in `~/.codex/sessions/` and confirm the files are all there; then check whether `~/.cc-switch/backups/` contains a `codex-official-history-unify-v1` directory—if even this directory is absent, you never triggered a migration and the sessions have been in their original drawer all along.
|
||||
|
||||
### Scenario F: Restore refused, toast "Unified session history was re-enabled; restore skipped"
|
||||
|
||||
**Symptom**: you turned off the switch -> checked restore -> but you were quick and immediately turned the switch back on, then saw the toast "Unified session history was re-enabled; restore skipped."
|
||||
|
||||
**The truth**: this is a safeguard against putting your data into a "torn" state, and again no sessions are lost. The restore action is "flip session tags from `custom` back to `openai`," but if the switch is on again at this moment, the live config is routing to `custom`—flipping history back to `openai` on one side while new sessions land in `custom` on the other would artificially tear sessions in two. So when CC Switch detects "the switch is on again," it **deliberately abandons this restore and changes nothing**. Sessions stay as they are, with no deletion or corruption.
|
||||
|
||||
**What to do**: to truly restore, **turn the switch off and keep it off** (don't immediately turn it back on), then do disable + check restore; to keep things unified, don't restore, and let the sessions stay in the `custom` shared drawer for normal use.
|
||||
|
||||
**The overriding principle: CC Switch's unify / migrate / restore only ever changes a single tag field in a session, and automatically backs up before every rewrite. It never deletes your conversations. Out of sight ≠ gone—look in the other drawer, or use the commands below to confirm with your own eyes.**
|
||||
|
||||
---
|
||||
|
||||
## Verify by hand: your session files are still on disk (the most important section)
|
||||
|
||||
No amount of text beats seeing it for yourself. Below are the **real paths** (taken from the CC Switch source) and how to view session files and backup directories on different systems. **The whole process is read-only and changes nothing; you're strongly encouraged to try it by hand.**
|
||||
|
||||
### The simplest way: open it directly in a file manager (no command line at all)
|
||||
|
||||
- **macOS (Finder)**: press `Cmd + Shift + G`, paste `~/.codex/sessions` and hit Enter to see a pile of `.jsonl` session files and their modification times; for the backup directory paste `~/.cc-switch/backups`.
|
||||
- **Windows (File Explorer)**: paste `%USERPROFILE%\.codex\sessions` into the address bar and hit Enter to see the session folders and the `.jsonl` files inside; for the backup directory paste `%USERPROFILE%\.cc-switch\backups`.
|
||||
|
||||
**As long as you can see a batch of `.jsonl` files here, that proves your session data is intact on disk.** The file count and modification times are more intuitive than any amount of text.
|
||||
|
||||
### Where exactly your session / history files live
|
||||
|
||||
| Content | Real path | Notes |
|
||||
|---|---|---|
|
||||
| **Session body (the core)** | `~/.codex/sessions/` (includes date-based subdirectories, recursive) | One `.jsonl` text file per session—**this is your conversation content** |
|
||||
| **Archived sessions** | `~/.codex/archived_sessions/` | Also `.jsonl` |
|
||||
| **Session index database** | `~/.codex/state_5.sqlite` | The `model_provider` column of the `threads` table is the "drawer tag"—**this is the actual classification source the resume list reads** |
|
||||
| **Migration backup** (auto-created when migration is enabled) | `~/.cc-switch/backups/codex-official-history-unify-v1/<timestamp>/` | Contains `jsonl/`, `state/`, `meta.json` |
|
||||
| **Restore backup** (auto-created when you restore) | `~/.cc-switch/backups/codex-official-history-unify-restore-v1/<timestamp>/` | A safety copy taken before restore |
|
||||
|
||||
> **Note**: if you've changed the Codex directory in CC Switch, or set `sqlite_home` in `config.toml`, replace `~/.codex` above with your actual directory. Below, `~` = your user home directory.
|
||||
|
||||
### macOS / Linux commands
|
||||
|
||||
**1. Count the total number of session files (this is the hard evidence of "nothing lost")**
|
||||
|
||||
```bash
|
||||
# Count the total number of session files -- as long as this number matches your expectation, the data is all there
|
||||
find ~/.codex/sessions ~/.codex/archived_sessions -name '*.jsonl' 2>/dev/null | wc -l
|
||||
|
||||
# Show the 10 most recently modified session files
|
||||
find ~/.codex/sessions -name '*.jsonl' 2>/dev/null -print0 \
|
||||
| xargs -0 ls -lt 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
**2. (Auxiliary) See how many sessions are in each "drawer"**
|
||||
|
||||
```bash
|
||||
# Number of session files in the official drawer (openai)
|
||||
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"openai"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||
|
||||
# Number of session files in the unified drawer (custom)
|
||||
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"custom"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||
|
||||
# See the tag distribution at a glance
|
||||
grep -rhoE '"model_provider"[[:space:]]*:[[:space:]]*"[^"]*"' ~/.codex/sessions 2>/dev/null | sort | uniq -c
|
||||
```
|
||||
|
||||
> **Important note, don't let this step scare you**: **early versions of Codex did not write the `model_provider` field into the `.jsonl`**, so these old official sessions **can't be counted** by the grep above—but they're still classified as `openai` in the index database `state_5.sqlite` and still show up in the resume list. So **judge "nothing lost" by the total file count from step 1**—the per-drawer grep is only there to help you understand the classification, and counting fewer than the total file count is **completely normal** and never means "a batch was lost."
|
||||
|
||||
**3. (Advanced) Query the index database `state_5.sqlite`—the classification the resume list actually reads**
|
||||
|
||||
```bash
|
||||
# Requires sqlite3 to be installed; skip if you don't have it
|
||||
sqlite3 ~/.codex/state_5.sqlite \
|
||||
"SELECT COALESCE(model_provider,'<empty>'), COUNT(*) FROM threads GROUP BY 1;"
|
||||
```
|
||||
|
||||
> This `threads` table is the actual classification source Codex's resume list reads; the `openai` row count ≈ the number of sessions you can see in your official drawer. It may not match step 2's jsonl grep—the reason is exactly what's described above: "old sessions don't write the jsonl field, but they're still openai in the index database." A mismatch between the two is not an anomaly.
|
||||
|
||||
**4. Read the content of a specific session directly (confirm the conversation text is still there)**
|
||||
|
||||
```bash
|
||||
# Replace <filename> with one of the .jsonl paths listed by ls above
|
||||
python3 -m json.tool < "<filename>.jsonl" 2>/dev/null | head -50
|
||||
|
||||
# Or just open it in an editor (plain text)
|
||||
open -e "<filename>.jsonl" # macOS
|
||||
```
|
||||
|
||||
**5. Look at CC Switch's backup directory (proof that a copy was kept before migration / restore)**
|
||||
|
||||
```bash
|
||||
ls -la ~/.cc-switch/backups/codex-official-history-unify-v1/ 2>/dev/null
|
||||
ls -la ~/.cc-switch/backups/codex-official-history-unify-restore-v1/ 2>/dev/null
|
||||
```
|
||||
|
||||
### Windows commands (PowerShell)
|
||||
|
||||
The session directory is usually at `C:\Users\<your username>\.codex\`, and backups at `C:\Users\<your username>\.cc-switch\backups\`.
|
||||
|
||||
```powershell
|
||||
# 1. Total number of session files (hard evidence of "nothing lost")
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions","$env:USERPROFILE\.codex\archived_sessions" -Recurse -Filter *.jsonl -ErrorAction SilentlyContinue).Count
|
||||
|
||||
# 2. The 10 most recently modified sessions
|
||||
Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 10 FullName,LastWriteTime
|
||||
|
||||
# 3. (Auxiliary) How many session files in the official (openai) / unified (custom) drawers
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Select-String -Pattern 'model_provider"\s*:\s*"openai"' -List).Count
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Select-String -Pattern 'model_provider"\s*:\s*"custom"' -List).Count
|
||||
|
||||
# 4. Look at the backup directories
|
||||
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-v1" -ErrorAction SilentlyContinue
|
||||
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-restore-v1" -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
> Same reminder: the step-3 grep counting **fewer** than the total file count is normal (old sessions don't write that field); judge "nothing lost" by the **total file count** from step 1.
|
||||
|
||||
---
|
||||
|
||||
## Advanced mechanism appendix (for users who want to truly understand how it works)
|
||||
|
||||
### 1. The bucketing mechanism (the essence of the drawers)
|
||||
|
||||
Codex's resume / history list filters by the currently active `model_provider` id with **exact string matching**. The **first line** of a session's `.jsonl` file is a `type:"session_meta"` record whose `payload.model_provider` is the drawer that session belongs to (`grep -rl` counts a file as long as the tag appears once anywhere in it, so no line-by-line parsing is needed; sessions from old versions that didn't write the field can't be counted). What actually drives the resume list is the `threads.model_provider` column of the index database `state_5.sqlite`. When `config.toml` has no explicit `model_provider`, the official subscription falls into the built-in default id `openai`; all of CC Switch's third-party providers uniformly use `custom`.
|
||||
|
||||
### 2. What the switch does (injection, lives only in live)
|
||||
|
||||
When enabled, CC Switch injects the following into the official live `config.toml`:
|
||||
|
||||
```toml
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "OpenAI"
|
||||
requires_openai_auth = true
|
||||
supports_websockets = true
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
Every field has a purpose: `requires_openai_auth = true` keeps authentication going through the ChatGPT login in `auth.json`, with the base_url defaulting back to the official Codex backend; `name = "OpenAI"` lets Codex's official feature gates (web search, remote compaction, etc.) keep matching; `supports_websockets = true` restores the capability that custom entries lose by default; `wire_api = "responses"` uses the official responses protocol. **The net effect is: authentication is unchanged, only the bucket name changed.**
|
||||
|
||||
**Key invariant: this injection can only exist in the live `config.toml`, and is never written into the database's stored configuration.** When you switch away from the official provider and write live back to the database, CC Switch strips this injection precisely (it strips only when the shape exactly matches the injected artifact; a third-party-customized `custom` table is kept as is). Precisely because of this, "turning off the switch + switching once" fully restores live, and the database always holds your original clean official configuration—this is the cornerstone of the whole switch's reversibility.
|
||||
|
||||
### 3. The two refusal gates for injection (corresponding to scenario C)
|
||||
|
||||
- `config.toml` already has an explicit `model_provider` -> don't override the user's route;
|
||||
- A differently-shaped `[model_providers.custom]` table already exists (possibly with a third-party `base_url`) -> refuse injection, otherwise ChatGPT OAuth traffic would be routed to the wrong backend.
|
||||
|
||||
When injection is refused, live is not unified, and the migration gate (checking whether live's `model_provider` equals `custom` after trim) judges `live_not_unified` -> skip migration, preserve intent, and do it later on the next startup retry. This is "safe deferral," not "failure with data loss."
|
||||
|
||||
### 4. The three session classes (which determine the migration / restore boundary)
|
||||
|
||||
- **Class A**: existing official sessions migrated in at enable time—the backup is the ledger, and they can be precisely restored back to `openai`;
|
||||
- **Class B**: created during the unified period—in no backup, and official / third-party can't be distinguished, so they're **never moved automatically** (stay `custom`);
|
||||
- **Class C**: pure third-party history from before enabling—never touched.
|
||||
|
||||
### 5. The safety of migration / restore (data is never truly deleted; where the guarantee comes from)
|
||||
|
||||
Four layers of design jointly guarantee that under **all paths, normal and abnormal**, the original session data is never truly deleted.
|
||||
|
||||
- **Only change the field, never the body**: migration / restore only switch the `model_provider` value in session metadata between `openai` and `custom`; conversation content, `response_item`, and `encrypted_content` are all kept exactly as is.
|
||||
- **Always copy a backup before a rewrite**: jsonl uses file copy, the state DB uses a full SQLite copy, both stored in a timestamped generation directory. Migration backups live in `codex-official-history-unify-v1/`, restore backups in the separate `codex-official-history-unify-restore-v1/`—the two are kept apart to keep the ledger clean.
|
||||
- **Only move, never delete + atomic writes**: all jsonl rewrites go through "temp file + whole-file replacement," and the state DB goes through a transactional `UPDATE`, with no deletion of any session or index at any point. The file is complete at every moment.
|
||||
- **Pessimistic skip + idempotent and retryable**: when buckets are inconsistent (`live_not_unified`), it would rather not migrate; a single process lock serializes migration and restore to avoid "startup retry / post-save background task / disable-time restore" concurrently rewriting the same batch of files in both directions; the completion marker is bound to the Codex directory and written conditionally to prevent missed migrations; restore uses the "in the ledger + currently still custom" dual condition to prevent wrong changes. Restore scans the union of all backup generations, so even after many switch cycles it can still restore early-migrated sessions; a repeated restore returns `nothing_to_restore`, which is idempotent protection rather than failure.
|
||||
|
||||
### 6. Cross-backend encrypted_content (corresponding to scenario B)
|
||||
|
||||
The reasoning ciphertext inside a session can only be decrypted by the backend that generated it; upstream Codex by design does not support cross-backend decryption. This is the root cause of "resume failure" and has nothing to do with file integrity—the session `.jsonl` sits fully on disk and `encrypted_content` is intact too. Switching back to the original provider to resume, or starting a new session, both work fine.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs: CC Switch Setup Guide](./codex-official-auth-preservation-guide-en.md)
|
||||
- [Using DeepSeek-Style Chat APIs in Codex: CC Switch Local Routing Guide](./codex-deepseek-routing-guide-en.md)
|
||||
- The "Codex App Enhancements" section in the CC Switch user manual
|
||||
|
||||
---
|
||||
|
||||
**One last word for you**: what you see as "sessions disappeared / resume failed" is essentially **the session being moved to another history list (drawer), or the other backend being unable to decrypt the old reasoning content**; the files always sit untouched in `~/.codex/sessions/` (and `state_5.sqlite`). Checking "restore from backup" when you turn off the switch precisely flips the official sessions you migrated in back to the official list; and even if you don't restore, both the original `.jsonl` files and the backup copies under `~/.cc-switch/backups/codex-official-history-unify-*/` are all still there—**the data is never truly lost.**
|
||||
@@ -0,0 +1,467 @@
|
||||
# Codex セッション履歴の統一: 機能紹介と利用ガイド(CC Switch)
|
||||
|
||||
> 対象バージョン: CC Switch v3.16.x 以降。本記事は現在のコードをもとに整理しており、コマンドとパスはご自身で検証できます。例示には匿名化したデータを使用しており、実際のセッション内容や API Key は含まれていません。
|
||||
|
||||
## この機能とは何か
|
||||
|
||||
「Codex セッション履歴を統一」は、CC Switch v3.16.x が Codex 向けに新しく追加したスイッチです。その場所は **設定 → 一般 → 「Codex アプリ拡張」グループ** の中にあります(「Codex アプリ拡張」はこのグループの見出しで、スイッチ自体は「Codex セッション履歴を統一」という名前です)。オンにすると、**公式サブスクリプション(ChatGPT ログイン / OpenAI API Key)のセッションが、CC Switch で管理するすべてのサードパーティプロバイダーのセッションと同じ履歴 / セッション再開リストに表示されます**——もう、互いに見えない 2 つのリストに分断されることはありません。
|
||||
|
||||
## どんな問題を解決するのか
|
||||
|
||||
Codex は「プロバイダーのラベル」(`model_provider` というフィールド)でセッションを分類しており、しかも **セッション再開 / 履歴リストには、現在アクティブなプロバイダーと同じラベルのセッションしか表示しません**。そのため、セッションは自然と 2 つの「引き出し」に分けられてしまいます。
|
||||
|
||||
- 公式サブスクリプションのセッションは、Codex 内蔵の **`openai`** ラベルに分類されます。
|
||||
- CC Switch が管理するすべてのサードパーティプロバイダーは、**`custom`** ラベルに分類されます。
|
||||
|
||||
2 つの引き出しは互いに見えません。**公式とサードパーティを頻繁に切り替えている** 場合、この分断に遭遇します——「さっき公式で話したセッションが、サードパーティに切り替えたら履歴リストから消えた」というように。実際にはなくなっておらず、別の引き出しに分けられただけです。この分断は、セッションが失われたと誤解させやすいうえに、すべてのセッションを 1 か所でまとめて振り返ったり再開したりするのにも不便です。
|
||||
|
||||
**このスイッチは、まさにこの分断を解消するためのものです**。公式サブスクリプションも `custom` ラベルで動作させることで、公式とサードパーティのセッションが同じリストに統合され、探すのも再開するのも 1 か所で済みます。
|
||||
|
||||
> ✅ **本記事全体を貫く重要な前提を、まず覚えておいてください**: この機能(統一 / 移行 / 復元)は **常にセッション記録内のあの分類ラベル `model_provider` 1 つだけを書き換え、しかも毎回書き換える前に自動で元ファイルをバックアップします**。あなたの会話を 1 文たりとも削除・消去・上書きすることはありません。ですので、本記事の後半で「あるセッションが見えなくなった」とあっても、そのほとんどは「別の引き出しに分けられた」だけであり、「データが消えた」わけではありません——本当に心配なときは、[症状対照表](#会話が消えた症状対照表) と [自分の目でファイルが残っていることを確認する](#自分の目で確認-セッションファイルはディスク上に残っている最重要セクション) を直接ご覧ください。
|
||||
|
||||
## 動作原理(一言版)
|
||||
|
||||
これを **2 つの引き出し + 自動バックアップ** と考えてください。
|
||||
|
||||
- デフォルトでは、公式セッションは `openai` の引き出しに、サードパーティのセッションは `custom` の引き出しにあり、互いに見えません。
|
||||
- スイッチは **公式も `custom` の引き出しを使うように** させ、2 つの引き出しを 1 つの共有リストに統合します。
|
||||
- **既存の公式の古いセッション** も一緒に共有の引き出しへ「移す」ことを選べます(この操作を **移行** と呼びます。任意で、能動的にチェックを入れる必要があります)。そして **いかなる移動の前にも、まずコピーをバックアップ** するので、プロセス全体が **可逆** です。
|
||||
- **認証はまったく影響を受けません**——公式サブスクリプションは引き続きあなたの ChatGPT ログインを使い、引き続き公式バックエンドを経由します。変わるのはセッションの分類ラベルだけです。
|
||||
|
||||
完全な仕組み(何が注入されるのか、なぜ可逆なのか、移行 / 復元がどうやってデータ消失を防ぐのか)は、後述の [コア・メンタルモデル](#コアメンタルモデル-2-つの引き出し--自動バックアップ) と巻末の [応用原理付録](#応用原理付録仕組みを本当に理解したい人向け) をご覧ください。
|
||||
|
||||
## 使い方(クイック)
|
||||
|
||||
1. **有効化**: 設定 → 一般 → Codex アプリ拡張 → 「Codex セッション履歴を統一」をオン → ダイアログで「既存の公式セッション履歴も移行する」にチェックを入れるか決める(**以前** の公式セッションも統一リストに合流させたいならチェックを入れる。今後だけ統一したいならチェックを入れない)→ 確定。詳しくは [有効化したとき何が起きるか](#有効化したとき何が起きるか-ステップ別解説) を参照。
|
||||
2. **無効化**: 同じスイッチをオフにする → ダイアログで「バックアップから正確に復元する」のチェックを保持(デフォルトでチェック済み)→ 確定すれば、移行した公式セッションを正確に公式リストへ戻せます。詳しくは [無効化したとき何が起きるか](#無効化したとき何が起きるか-ステップ別解説) を参照。
|
||||
3. **セッションが消えた気がする?** 慌てずに [症状対照表](#会話が消えた症状対照表) へ進んで症状から原因を特定し、[自分の目で確認](#自分の目で確認-セッションファイルはディスク上に残っている最重要セクション) セクションのコマンドで、ファイルがすべて残っていることを自分の目で確かめてください。
|
||||
|
||||
---
|
||||
|
||||
## コア・メンタルモデル: 2 つの引き出し + 自動バックアップ
|
||||
|
||||
この機能を理解するには、**引き出し** と **バックアップ** の 2 つだけ覚えれば十分です。
|
||||
|
||||
### 引き出し: Codex はどうやってセッションを分類するか
|
||||
|
||||
Codex セッションを 1 つ開くたびに、Codex はセッションファイルの先頭に `model_provider` というラベルを記録し、「このセッションはどのプロバイダーで話したか」を示します。Codex の **セッション再開 / 履歴リストは、現在アクティブなこのラベルで正確にフィルタリングされます**——「今あなたが使っているプロバイダー」と同じラベルのセッションだけが表示されます。
|
||||
|
||||
- 公式サブスクリプション(ChatGPT ログイン / OpenAI API Key)のセッションのラベルは、内蔵の **`openai`** です。
|
||||
- CC Switch が管理するすべてのサードパーティプロバイダーは、一律にラベル **`custom`** を使います。
|
||||
|
||||
そのためデフォルトでは、公式セッションとサードパーティセッションは生まれつき互いに見えません——2 つの異なる引き出しにあるからです。これは **Codex 自身の設計** であり、CC Switch が何かをなくしたわけではありません。
|
||||
|
||||
```text
|
||||
デフォルト状態(統一スイッチをオンにしていない):
|
||||
┌──────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ openai の引き出し │ │ custom の引き出し │
|
||||
│ (公式セッション) │ │ (サードパーティのセッション) │
|
||||
└──────────────────────┘ └──────────────────────────────────┘
|
||||
▲ ▲
|
||||
公式のときは サードパーティのときは
|
||||
こちらだけ表示 こちらだけ表示
|
||||
|
||||
(2 つの引き出しは互いに見えない)
|
||||
```
|
||||
|
||||
**「Codex セッション履歴を統一」スイッチがすることは、公式サブスクリプションも `custom` ラベルで動作させ、2 つの引き出しを 1 つに統合することです**。その結果、公式セッションとサードパーティセッションが同じセッション再開リストに表示されます。注意してほしいのは、**認証は変わらない** ということです——あなたの公式サブスクリプションは引き続き ChatGPT ログインを使い、引き続き公式バックエンドを経由します。変わるのはセッションの「分類ラベル」が `openai` から `custom` になることだけです。
|
||||
|
||||
```text
|
||||
統一スイッチをオンにした後:
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ custom 共有引き出し │
|
||||
│ 公式セッション + サードパーティのセッション │
|
||||
│ (同じ履歴 / 再開リストに表示される) │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### バックアップ: ラベルを変更する前に必ずコピーを取る
|
||||
|
||||
「引き出しの統合」では、一部の公式セッションのラベルを `openai` から `custom` に変更する必要があります(この操作を **移行** と呼び、これは **任意で、あなたが能動的にチェックを入れる必要があります**)。そして **どの書き換えの前にも、CC Switch はまず元ファイルをそのままコピー** して、ここに保存します。
|
||||
|
||||
```text
|
||||
~/.cc-switch/backups/codex-official-history-unify-v1/<時間スタンプ>/
|
||||
```
|
||||
|
||||
このバックアップが、後の「バックアップから正確に復元する」ための唯一の拠り所です。これによってプロセス全体が **可逆** になります——いつでもスイッチをオフにして、移行した公式セッションを正確に `openai` の引き出しへ戻せます。
|
||||
|
||||
この 2 つの言葉——**引き出し**(セッションは分類が変わるだけ)、**バックアップ**(変更前に必ずコピー)——を覚えておけば、以降の内容はすべて簡単に理解できます。
|
||||
|
||||
---
|
||||
|
||||
## 有効化したとき何が起きるか: ステップ別解説
|
||||
|
||||
### Step 1: スイッチを見つける
|
||||
|
||||
```text
|
||||
設定 → 一般 → Codex アプリ拡張
|
||||
```
|
||||
|
||||
「Codex アプリ拡張」のセクションには 2 行のスイッチがあり、**2 行目**(青い履歴アイコン)が本ガイドの主役です。
|
||||
|
||||
> **Codex セッション履歴を統一**
|
||||
|
||||
その下には説明文があります(逐語)。
|
||||
|
||||
> オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。
|
||||
|
||||
> **注意**: この説明文には、すでに 3 つのことが予告されています——同じリストに表示される、移行を選べて自動バックアップされる、プロバイダーをまたいだ再開は「失敗する場合がある」。ここでの「再開に失敗する」は **続けられない、新しいターンを生成できない** という意味であり、「記録が消える」ではありません。これこそ、この後で重点的に解きほぐす核心的な誤解です。
|
||||
|
||||
### Step 2: スイッチをオフからオンに切り替える → 確認ダイアログが表示される
|
||||
|
||||
スイッチをオンに切り替えると、CC Switch は **すぐには保存せず**、まず確認ダイアログを表示します。ダイアログの文言は次のとおりです(逐語)。
|
||||
|
||||
- **タイトル**: Codex セッション履歴を統一
|
||||
- **本文**:
|
||||
|
||||
> オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。
|
||||
>
|
||||
> 既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。
|
||||
|
||||
- **チェックボックス**: 既存の公式セッション履歴も移行する
|
||||
- **確認ボタン**: 理解しました、オンにする
|
||||
- **キャンセルボタン**: キャンセル
|
||||
|
||||
**このチェックボックスはデフォルトでオフです。** これは重要な分岐点です。
|
||||
|
||||
| あなたの選択 | 効果 | この時点でデータはどこにあるか |
|
||||
|---|---|---|
|
||||
| **チェックしない**(デフォルト) | ラベルを切り替えるだけ。**オンにした後に新規作成された公式セッションだけ** が `custom` の共有引き出しに入る | あなたが **オンにする前** の公式の古いセッションは、ラベルが `openai` のまま、その場で動かず、引き続き `~/.codex/sessions/` にある |
|
||||
| **チェックする** | ラベルの切り替えに加えて、**既存の公式の古いセッション** も `openai` の引き出しから `custom` の引き出しへ移行する | 古いセッションは **コピーしてバックアップ** された後、ラベルが `custom` に書き換えられる。元データはバックアップで保護される |
|
||||
|
||||
> **「以前の公式セッションも統一リストに表示したい」なら、必ずこのチェックボックスを能動的にオンにしてください。** さもないと、下の対照表の「シナリオ A」に遭遇します——古いセッションが「消えた」ように見えますが、実際は元の引き出しに残っているだけです。
|
||||
|
||||
「キャンセル」を押すか、ダイアログの外側をクリックすると、スイッチはそのままオフ状態に戻り、何も起きません。
|
||||
「理解しました、オンにする」を押すと、スイッチはオンとして保存され、CC Switch はバックグラウンドで設定をディスクに書き込みます(移行にチェックを入れていれば、移行を実行します)。
|
||||
|
||||
### Step 3(移行にチェックを入れた場合のみ): 移行はどう実行されるか + データの安全性
|
||||
|
||||
「既存の公式セッション履歴も移行する」にチェックを入れた場合、CC Switch はあなたの公式の古いセッションに対して、次の一連の流れを実行します。
|
||||
|
||||
```text
|
||||
公式(openai ラベル)の各セッションファイルについて:
|
||||
① まず元ファイルをそのままバックアップディレクトリへコピー ← データの一次保険ができる
|
||||
② 「一時ファイルに書く → まるごと置換」という原子的な方法で、
|
||||
先頭行 session_meta 内の model_provider を
|
||||
"openai" から "custom" へ変更するだけ ← 会話本文は 1 バイトも触らない
|
||||
③ インデックス DB state_5.sqlite も同じトランザクション内でラベルを変更
|
||||
```
|
||||
|
||||
- **バックアップの場所**: `~/.cc-switch/backups/codex-official-history-unify-v1/<時間スタンプ>/`。移行のたびに、タイムスタンプ付きの「世代ディレクトリ」を生成し、その中に `jsonl/`(セッションのコピー)、`state/`(インデックス DB のコピー)、`meta.json`(この移行がどの Codex ディレクトリに属するかの記録)が含まれます。
|
||||
- **変更するもの**: `model_provider` というフィールドの値だけ。あなたの会話内容、推論内容、すべての本文は **そのまま保持** されます。
|
||||
- **削除するもの**: **何も削除しません**。バックアップは「コピー」、書き換えは「同一ファイルの原子的な置換」であり、全工程でセッションやインデックスを削除する操作は一切ありません。ファイルはいかなる時点でも完全です(古い内容か新しい内容かのどちらかであり、空や中途半端になることは決してありません)。
|
||||
|
||||
移行が成功すると、これらの公式の古いセッションが統一リストに表示されます。**この時点でのあなたのデータ**: ① 元のコピーがバックアップディレクトリにある。② アクティブファイルは分類ラベルが変わっただけで、内容は無傷。
|
||||
|
||||
> **注意**: 有効化と移行そのものは **成功通知を表示しません**。移行は保存時にバックエンドが付随的に実行するもので、UI 上ではスイッチがオン状態になったのが見えるだけです。ですので「移行成功のダイアログが見えなかった」のは正常であり、失敗を意味しません。
|
||||
|
||||
---
|
||||
|
||||
## 無効化したとき何が起きるか: ステップ別解説
|
||||
|
||||
### Step 1: スイッチをオンからオフに切り替える → バックアップを探索 → 確認ダイアログが表示される
|
||||
|
||||
無効化のとき、CC Switch はまず **一瞬かけて移行バックアップの有無を探索** し、それから確認ダイアログを表示します(そのため無効化のダイアログは少しだけ遅延しますが、これは正常です)。文言は次のとおりです(逐語)。
|
||||
|
||||
- **タイトル**: セッション履歴の統一をオフにする
|
||||
- **本文**:
|
||||
|
||||
> オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。
|
||||
|
||||
- **チェックボックス**(条件付き表示): オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)
|
||||
- **確認ボタン**: オフにする
|
||||
- **キャンセルボタン**: キャンセル
|
||||
|
||||
> **ポイント**: 本文が言っているのは「公式サブスクリプションからは **見えなくなる**」——**見えなくなる** であり、**削除される** ではありません。オン期間中に新たに話したセッションは、引き続き `custom` の引き出しに完全な形で残っており、オフにした後で公式側から見えなくなるだけです。
|
||||
|
||||
**この復元チェックボックスはデフォルトでオンです。** つまりデフォルトの動作は「オフにすると同時に、移行した公式セッションを公式履歴へ正確に復元する」です。チェックを保持したまま「オフにする」を押すだけで構いません。
|
||||
|
||||
チェックボックスが **表示されない** 場合は、復元が必要なバックアップがないとシステムが判断したことを意味します(移行に一度もチェックを入れていない、またはバックアップを探索できない)——この場合、あなたの公式の古いセッションは一度も変更されていないので、スイッチをオフにすれば自然と `openai` の引き出しに戻ります。
|
||||
|
||||
### Step 2: 復元はどう実行されるか(バックアップ台帳に従って正確に戻す)
|
||||
|
||||
チェックを保持して「オフにする」を押すと、CC Switch の復元フローは次のようになります。
|
||||
|
||||
```text
|
||||
① まず現在の状態を独立した復元バックアップディレクトリへもう一度コピー
|
||||
~/.cc-switch/backups/codex-official-history-unify-restore-v1/<時間スタンプ>/
|
||||
(復元自体もまずバックアップするので、復元でもデータは失われない)
|
||||
② すべての移行バックアップ世代を走査し、「当初のラベルが openai」のセッション id を集めて「台帳」を作る
|
||||
③ 【台帳に含まれ、かつ現在もまだ custom】のセッションだけ、ラベルを "openai" に戻す
|
||||
```
|
||||
|
||||
③ のステップの **二重条件** に注意してください——台帳に含まれていること(当初確かに公式から移行されたものだと証明できる)に加えて、現在もまだ `custom` であること(あなたが手動で変更していないことを示す)。両方の条件を満たして初めて戻します。これにより、復元は正確であり、かつ誤って手を加えることもありません。
|
||||
|
||||
**この時点でのあなたのデータ**: 戻された公式セッションはラベルが `openai` に変わり、再び公式リストに表示されます。同時に、移行バックアップと復元バックアップの 2 つのコピーがどちらもディスク上に残っています。
|
||||
|
||||
### Step 3: 通知を見て、結果を確認する
|
||||
|
||||
「オフにする + 復元にチェック」というパスだけが結果通知を表示します。表示され得る通知(逐語)。
|
||||
|
||||
| 表示される通知 | 意味 |
|
||||
|---|---|
|
||||
| **バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)** | 復元成功。`{{files}}` / `{{rows}}` の部分には実際の数字が表示される |
|
||||
| **現在の Codex ディレクトリに復元可能な移行バックアップはありません** | 復元できる内容がない(**データが消えたわけではない**。対照表シナリオ E を参照) |
|
||||
| **統一セッション履歴が再度有効化されたため、復元をスキップしました** | 復元のキュー待ち中にスイッチを再びオンにしたため、システムが復元を自発的に取りやめた(対照表シナリオ F を参照) |
|
||||
| **公式セッション履歴の復元に失敗しました。もう一度お試しください** | 復元の途中でエラー。もう一度試せばよく、データは破壊されていない |
|
||||
| **保存に失敗しました。もう一度お試しください** | オフにするステップの保存そのものが失敗。この場合 **復元は決して起動されず**、スイッチは元の位置に戻る |
|
||||
|
||||
> **気の利いた安全設計**: 「スイッチをオフにする」ステップの保存が失敗した場合、CC Switch は **復元を決して実行しません**。さもないと「スイッチはまだオン、しかしセッションは `openai` バケットに戻された」という矛盾状態が生じてしまいます。保存失敗時、スイッチは **自動で元の位置に戻る** ので、「オフに見えるのに実は保存されていない」という偽の状態に取り残されることはありません。
|
||||
|
||||
---
|
||||
|
||||
## 「会話が消えた?」症状対照表
|
||||
|
||||
以下の 6 つのシナリオは、ユーザーが最も「セッションが消えた」と誤解しやすいケースです。**どれも真相は: データは無傷で、引き出しが変わったか一時的に見えないだけ。** まずこの表で症状から原因を特定し、その後で下の詳細説明を読んでください。
|
||||
|
||||
| シナリオ | あなたが見るもの | データの真相 | 一言での解決法 |
|
||||
|---|---|---|---|
|
||||
| **A** 移行にチェックなし | 公式の古いセッションが統一リストにない | すべて存在、`openai` ラベルのまま | 移行にチェックを入れて再度オンにする、またはスイッチをオフにする |
|
||||
| **B** プロバイダーをまたいだ再開が失敗 | 続けられない / エラー | ファイルは無傷、暗号文がバックエンドをまたいで復号できないだけ | 元のプロバイダーで再開する。内容だけ見るなら jsonl を直接読む |
|
||||
| **C** プロキシ接管 / 注入が拒否 | 移行も復元もされない | 移行が安全にスキップされ、ファイルは未変更 | 接管を終了 → 再起動して再試行。またはスイッチを直接オフにする |
|
||||
| **D** 復元後、新セッションが公式に戻らない | オン期間中の新セッションが公式にない | `custom` の引き出しにある、設計上動かさない | サードパーティプロバイダーに切り替えれば見える |
|
||||
| **E** 「復元可能なバックアップなし」と通知 | 復元が「失敗」 | 通常はそもそも移行していない、セッションは元の引き出しにある | スイッチをオフにすれば公式セッションが自動で再表示 |
|
||||
| **F** 「スイッチが再度有効化、復元スキップ」と通知 | 復元が拒否 | データの矛盾を防止、何も変更していない | まずスイッチを完全にオフにしてから復元する |
|
||||
|
||||
### シナリオ A: スイッチをオンにしたが移行にチェックを入れなかった → 公式の古いセッションが「消えた」
|
||||
|
||||
**現象**: 統一スイッチをオンにしたが、有効化ダイアログの「既存の公式セッション履歴も移行する」にチェックを入れなかった(デフォルトでチェックなし)。オンにした後で見ると、以前の公式の古いセッションがすべてリストにないように見える。
|
||||
|
||||
**真相**: データは 100% すべて存在し、1 行も動いていません。スイッチは「オンにした後に新規作成された」公式セッションにのみ効きます。あなたが **オンにする前** の公式の古いセッションはラベルが `openai` のままで、そっくりそのまま `~/.codex/sessions/` に横たわっています。今あなたがアクティブにしているのは `custom` の引き出しなので、`openai` の引き出しに残った古いセッションが見えないのは当然です——これが「消えたように見える」理由のすべてです。
|
||||
|
||||
**どうするか**(いずれか):
|
||||
1. **スイッチを再度オンにするときに「既存の公式セッション履歴も移行する」にチェックを入れ**、古いセッションを `custom` の引き出しへ移せば、すぐに統一リストに表示されます(書き換え前に自動バックアップ)。
|
||||
2. **または単に統一スイッチをオフにする** と、公式は再び `openai` の引き出しで動作し、古いセッションがその場で再表示されます。
|
||||
|
||||
### シナリオ B: プロバイダーをまたいで古いセッションを再開して失敗 → 「このセッションが壊れた / 消えた」と思う
|
||||
|
||||
**現象**: 統一した後、リストに「別のプロバイダー」で話した古いセッションが見える。今のプロバイダーに切り替えて「再開」を押すと、エラーになったり繋がらなかったりする。
|
||||
|
||||
**真相**: セッションファイルは完全に無傷で、失われたのはデータではなく「バックエンドをまたいだ復号能力」です。Codex セッションには暗号化された推論内容 `encrypted_content` が保存されており、**この暗号文は、それを生成したバックエンドだけが復号できます**。B プロバイダーで A プロバイダーが生成したセッションを再開しようとすると、B は A の暗号文を解けない → 再開失敗。これは **上流の Codex の設計上の制約(by design)** であり、CC Switch がファイルに手を加えたかどうかとは無関係です。セッション内の文字内容はいつでも読めます。
|
||||
|
||||
> これは本記事全体で **唯一「本当に問題が起きたように見える」実在の例外** です——ただし注意してください: これは **再開できない(新しいターンを生成できない)** だけであり、**元ファイルは依然として完全に存在し**、会話の文字はいつでも読めます。
|
||||
|
||||
**どうするか**:
|
||||
- **「このセッションを最初に作成したプロバイダー」で再開すれば**、正常に復号でき、繋がります。
|
||||
- 履歴の内容だけ見たくて、続ける必要がない場合は、そのセッションの `.jsonl` ファイルを直接読んでください(巻末にコマンドあり)。
|
||||
- 経験則: **プロバイダーをまたぐ場合は「新規セッションを始める」のが向いており、古いセッションはできるだけ元のプロバイダーで再開してください。**
|
||||
|
||||
### シナリオ C: スイッチをオンにし移行にもチェックを入れたが、移行が静かにスキップされた → 「移行がセッションをなくした」と思う
|
||||
|
||||
**現象**: オンにして移行にチェックを入れたのに、公式の古いセッションは統一リストに入らず、スイッチをオフにして復元しようとしても「復元できるものがない」と通知される(または無効化ダイアログに復元チェックボックスがそもそも現れない。シナリオ E を参照)。あなたは、移行の過程でセッションをなくしたのではと疑います。
|
||||
|
||||
**真相**: 移行はそもそも **実行されていない** ので、なくすことも不可能です——あなたのセッションは 1 文字も変更されていません。CC Switch には移行前に安全ゲートがあります: Codex の live 設定(`~/.codex/config.toml`)が、この時点で **本当に** 共有の `custom` 引き出しへルーティングされているかを確認し、本当にルーティングされている場合だけ移行します。以下の 2 つのケースでは「まだ統一されていない」と判定され(内部の理由コード `live_not_unified`)、**移行を自発的にスキップし、あなたのスイッチと移行の意思は保持し、条件が満たされてから移行します**。
|
||||
|
||||
- **プロキシ接管中**: CC Switch のプロキシが live 設定を接管しており、接管中の live には統一ルーティングのマークが付いていません。
|
||||
- **注入が拒否された**: あなたの `config.toml` にすでに手動指定の `model_provider` があるか、形態の異なる `[model_providers.custom]` テーブルが既に存在する(サードパーティのアドレスが付いている可能性がある)。公式トラフィックを誤ってサードパーティバックエンドへルーティングするのを避けるため、CC Switch は注入も移行もしないことを選びます。
|
||||
|
||||
移行のスキップ = どのセッションファイルにも触れない。**移行していない=動かしていない、消えようがない。** これは「安全な先送り」であり、「失敗してデータが消えた」ではありません。
|
||||
|
||||
**どうするか**:
|
||||
- プロキシ接管を終了 → **CC Switch を再起動**: 起動時に自動で移行を再試行します(あなたの移行の意思はずっと保持されています)。
|
||||
- `~/.codex/config.toml` を確認: 手動で書いた競合するルーティングがあれば、競合を整理してからスイッチをオンにします。
|
||||
- どうしても手間をかけたくない場合は、スイッチをオフにすれば、公式セッションは引き続き `openai` の引き出しで正常に表示され、まったく無傷です。
|
||||
|
||||
### シナリオ D: スイッチをオフにして復元したが、「オン期間中に新たに話したセッション」が公式に戻らない → 「新セッションが消えた」と思う
|
||||
|
||||
**現象**: 統一をオンにしている間、公式でさらにいくつかの新セッションを話した。後でスイッチをオフにし、復元にチェックを入れた。復元が終わると、その数本の新セッションが公式の引き出しに戻っていない。
|
||||
|
||||
**真相**: これは **意図的な** 設計で、新セッションはちゃんと `custom` の引き出しにあり、見えるし続けられます。復元の拠り所は「移行時のバックアップ台帳」です——**当初 `openai` の引き出しから移行されてきたセッションだけ** がバックアップに記録されており、正確に `openai` へ戻されます。あなたが **オン期間中に新規作成した** セッションはどのバックアップ台帳にもありません。しかも統一後は公式もサードパーティも `custom` ラベルを使うので、**CC Switch はこの新セッションが公式で話したものかサードパーティで話したものか判別できません**。サードパーティのセッションを公式履歴に誤って押し込まないため、プロダクトの決定として、これらの新セッションは一律に `custom`(サードパーティ)の履歴に残し、決して自動で動かしません。無効化ダイアログの文言もこれを明示しています——「オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残ります」。
|
||||
|
||||
**どうするか**:
|
||||
- 任意のサードパーティプロバイダー(`custom` の引き出し)に切り替えれば、履歴リストでこれらのセッションが見えます。
|
||||
- 内容を見たいなら `.jsonl` を直接読み、再開したいならシナリオ B のルール(それを生成した元のバックエンドに戻る)に従ってください。
|
||||
- もし **ある 1 本** を手動で公式に戻したい場合: 現在は自動ボタンはありません(方向を誤判定するのを避けるため、あえて作っていません)。上級ユーザーは、そのファイルを **先にバックアップ** したうえで、`.jsonl` の 1 行目 `session_meta` 内の `model_provider` を `custom` から `openai` に手動で戻せます(上級操作です。変更前に必ずコピーを取ってください)。
|
||||
|
||||
### シナリオ E: 復元時に「現在の Codex ディレクトリに復元可能な移行バックアップはありません」と通知 → 「復元失敗 = データが消えた」と思う
|
||||
|
||||
**現象**: スイッチをオフにするときに復元にチェックを入れたら、「現在の Codex ディレクトリに復元可能な移行バックアップはありません」と通知が出た。あなたは慌てます: 復元すら失敗した、データは完全に消えたのでは?
|
||||
|
||||
**真相**: 「復元できるものがない」≠「データが消えた」。むしろ逆で、通常は **そもそも復元すべき移行が存在しない** からです。よくある原因:
|
||||
|
||||
- **当初「既存の公式セッションを移行する」にチェックを入れていない**: 移行していない以上、移行バックアップもなく、戻すべきセッションもありません。あなたの公式の古いセッションはずっと `openai` の引き出しにあり、スイッチをオフにすれば直接再表示されます(シナリオ A と同じ)。(この場合、無効化ダイアログは復元チェックボックスを **そもそも表示しない** こともあります——システムがバックアップを一切探索できないためです。)
|
||||
- **すでに一度復元済み**: セッションラベルはすべて `openai` に戻っており、もう一度押しても「まだ `custom` の対象がない」のは当然です——これは **冪等保護であり、失敗ではありません**。
|
||||
- **Codex ディレクトリを切り替えた**: 復元は **現在の** ディレクトリに属するバックアップ台帳しか認識しないので、ディレクトリを変えると旧ディレクトリの台帳が見つかりません。ディレクトリを戻せば解決します。
|
||||
|
||||
この 3 つのケースでは、どのセッションも削除されていません。
|
||||
|
||||
**どうするか**: 巻末のコマンドで `~/.codex/sessions/` 内のセッションファイル総数を数え、ファイルがすべて残っていることを確認してください。次に `~/.cc-switch/backups/` に `codex-official-history-unify-v1` ディレクトリがあるかを見てください——もしこのディレクトリすらなければ、あなたは一度も移行を起動しておらず、セッションはずっと元の引き出しにある、ということです。
|
||||
|
||||
### シナリオ F: 復元が拒否され、「統一セッション履歴が再度有効化されたため、復元をスキップしました」と通知
|
||||
|
||||
**現象**: スイッチをオフにする → 復元にチェック → 手が速くて、すぐにスイッチを再びオンにした。すると「統一セッション履歴が再度有効化されたため、復元をスキップしました」と通知が出た。
|
||||
|
||||
**真相**: これはデータを「矛盾」状態にしてしまうのを防ぐ防護であり、セッションは同じく消えていません。復元の動作は「セッションラベルを `custom` から `openai` へ戻す」ことですが、この時点でスイッチが再びオンになっていると、live 設定は `custom` へルーティングしています——一方で履歴を `openai` へ戻し、一方で新セッションを `custom` に落とせば、セッションが人為的に 2 つに引き裂かれてしまいます。そのため CC Switch は「スイッチが再びオンになった」のを検知すると、**この復元を自発的に取りやめ、何も変更しません**。セッションは現状を維持し、削除も破壊もありません。
|
||||
|
||||
**どうするか**: 本当に復元したいなら、**まずスイッチを安定してオフにし**(すぐにオンにし直さない)、それから「オフにする + 復元にチェック」を実行してください。統一を保ちたいなら、復元せず、セッションを `custom` の共有引き出しに残して通常どおり使ってください。
|
||||
|
||||
**大原則: CC Switch の統一 / 移行 / 復元は、全工程でセッションの 1 つのラベルフィールドだけを変更し、しかも毎回書き換える前に自動でバックアップします。あなたの会話を削除することはありません。見えない ≠ 消えた——別の引き出しを見るか、下のコマンドで自分の目で確かめてください。**
|
||||
|
||||
---
|
||||
|
||||
## 自分の目で確認: セッションファイルはディスク上に残っている(最重要セクション)
|
||||
|
||||
文字をいくら重ねるより、自分の目で見るのが一番です。以下に **実際のパス**(CC Switch のソースコードから取得)と、異なる OS でセッションファイル・バックアップディレクトリを見る方法を示します。**全工程は読み取りのみで変更なし。ぜひ一度ご自身で試してみてください。**
|
||||
|
||||
### 最も簡単な方法: ファイルマネージャーで直接開く(コマンドライン完全不要)
|
||||
|
||||
- **macOS(Finder)**: `Cmd + Shift + G` を押して `~/.codex/sessions` を貼り付けて Enter すれば、たくさんの `.jsonl` セッションファイルとその更新時刻が見えます。バックアップディレクトリは `~/.cc-switch/backups` を貼り付けます。
|
||||
- **Windows(エクスプローラー)**: アドレスバーに `%USERPROFILE%\.codex\sessions` を貼り付けて Enter すれば、セッションフォルダとその中の `.jsonl` が見えます。バックアップディレクトリは `%USERPROFILE%\.cc-switch\backups` を貼り付けます。
|
||||
|
||||
**ここで一連の `.jsonl` ファイルが見えれば、それがセッションデータが無傷でディスク上にある証拠です。** ファイル数や更新時刻は、どんな文章よりも直感的です。
|
||||
|
||||
### あなたのセッション / 履歴ファイルはどこにあるのか
|
||||
|
||||
| 内容 | 実際のパス | 説明 |
|
||||
|---|---|---|
|
||||
| **セッション本文(コア)** | `~/.codex/sessions/`(日付別サブディレクトリを含む、再帰的) | セッション 1 つにつき 1 つの `.jsonl` テキストファイル。**これがあなたの会話内容** |
|
||||
| **アーカイブ済みセッション** | `~/.codex/archived_sessions/` | 同じく `.jsonl` |
|
||||
| **セッションインデックス DB** | `~/.codex/state_5.sqlite` | `threads` テーブルの `model_provider` 列が「引き出しラベル」。**これこそ、セッション再開リストが実際に読み取る分類のソース** |
|
||||
| **移行バックアップ**(移行をオンにすると自動生成) | `~/.cc-switch/backups/codex-official-history-unify-v1/<時間スタンプ>/` | `jsonl/`、`state/`、`meta.json` を含む |
|
||||
| **復元バックアップ**(復元を押すと自動生成) | `~/.cc-switch/backups/codex-official-history-unify-restore-v1/<時間スタンプ>/` | 復元前の安全なコピー |
|
||||
|
||||
> **注意**: CC Switch で Codex ディレクトリを変更した場合や、`config.toml` で `sqlite_home` を設定している場合は、上記の `~/.codex` をあなたの実際のディレクトリに置き換えてください。以下の `~` = あなたのユーザーホームディレクトリ。
|
||||
|
||||
### macOS / Linux コマンド
|
||||
|
||||
**1. セッションファイル総数を数える(これこそ「消えていない」確固たる証拠)**
|
||||
|
||||
```bash
|
||||
# セッションファイルの総数を数える —— この数が想定どおりなら、データはすべて残っている
|
||||
find ~/.codex/sessions ~/.codex/archived_sessions -name '*.jsonl' 2>/dev/null | wc -l
|
||||
|
||||
# 最近更新されたセッションファイル上位 10 件を見る
|
||||
find ~/.codex/sessions -name '*.jsonl' 2>/dev/null -print0 \
|
||||
| xargs -0 ls -lt 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
**2. (補助)各「引き出し」にそれぞれ何個のセッションがあるか見る**
|
||||
|
||||
```bash
|
||||
# 公式の引き出し(openai)のセッションファイル数
|
||||
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"openai"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||
|
||||
# 統一の引き出し(custom)のセッションファイル数
|
||||
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"custom"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||
|
||||
# 各ラベルの分布をひと目で確認
|
||||
grep -rhoE '"model_provider"[[:space:]]*:[[:space:]]*"[^"]*"' ~/.codex/sessions 2>/dev/null | sort | uniq -c
|
||||
```
|
||||
|
||||
> **重要なヒント、このステップに驚かないでください**: **初期バージョンの Codex は `.jsonl` に `model_provider` フィールドを書き込みません**。これらの古い公式セッションは上記の grep では **数えられません** が、インデックス DB `state_5.sqlite` では依然として `openai` に分類されており、セッション再開リストではちゃんと見えます。ですので **「セッションが消えていない」かの判断はステップ 1 のファイル総数を基準にしてください**——バケット別 grep は分類を理解する補助に過ぎず、数えた結果がファイル総数より少ないのは **まったく正常** であり、決して「ひとまとまり消えた」ことを意味しません。
|
||||
|
||||
**3. (応用)インデックス DB `state_5.sqlite` を見る——セッション再開リストが実際に読む分類**
|
||||
|
||||
```bash
|
||||
# sqlite3 がインストール済みであること;未インストールならスキップ可
|
||||
sqlite3 ~/.codex/state_5.sqlite \
|
||||
"SELECT COALESCE(model_provider,'<空>'), COUNT(*) FROM threads GROUP BY 1;"
|
||||
```
|
||||
|
||||
> この `threads` テーブルこそ、Codex のセッション再開リストが実際に読み取る分類のソースであり、`openai` の行数 ≈ あなたの公式の引き出しで見えるセッション数です。ステップ 2 の jsonl grep とは数が合わないことがあります——その理由は、上述の「古いセッションは jsonl フィールドを書き込まないが、インデックス DB では依然として openai」だからです。両者が合わないのは異常ではありません。
|
||||
|
||||
**4. あるセッションの内容を直接読む(会話の文字が残っていることを確認)**
|
||||
|
||||
```bash
|
||||
# <ファイル名> を、上の ls で表示された .jsonl のパスに置き換える
|
||||
python3 -m json.tool < "<ファイル名>.jsonl" 2>/dev/null | head -50
|
||||
|
||||
# またはエディタで直接開いて見る(プレーンテキスト)
|
||||
open -e "<ファイル名>.jsonl" # macOS
|
||||
```
|
||||
|
||||
**5. CC Switch のバックアップディレクトリを見る(移行 / 復元の前に必ずコピーを残した証拠)**
|
||||
|
||||
```bash
|
||||
ls -la ~/.cc-switch/backups/codex-official-history-unify-v1/ 2>/dev/null
|
||||
ls -la ~/.cc-switch/backups/codex-official-history-unify-restore-v1/ 2>/dev/null
|
||||
```
|
||||
|
||||
### Windows コマンド(PowerShell)
|
||||
|
||||
セッションディレクトリは通常 `C:\Users\<あなたのユーザー名>\.codex\` にあり、バックアップは `C:\Users\<あなたのユーザー名>\.cc-switch\backups\` にあります。
|
||||
|
||||
```powershell
|
||||
# 1. セッションファイルの総数(「消えていない」ことの動かぬ証拠)
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions","$env:USERPROFILE\.codex\archived_sessions" -Recurse -Filter *.jsonl -ErrorAction SilentlyContinue).Count
|
||||
|
||||
# 2. 最近更新されたセッション上位 10 件
|
||||
Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 10 FullName,LastWriteTime
|
||||
|
||||
# 3. (補助)公式(openai) / 統一(custom) の引き出しにそれぞれ何件のセッションファイルがあるか
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Select-String -Pattern 'model_provider"\s*:\s*"openai"' -List).Count
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Select-String -Pattern 'model_provider"\s*:\s*"custom"' -List).Count
|
||||
|
||||
# 4. バックアップディレクトリを見る
|
||||
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-v1" -ErrorAction SilentlyContinue
|
||||
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-restore-v1" -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
> 同じく注意: ステップ 3 の grep の数がファイル総数より **少なくなる** のは正常です(古いセッションはこのフィールドを書き込まないため)。「セッションが消えていない」の判断は、ステップ 1 の **ファイル総数** を基準にしてください。
|
||||
|
||||
---
|
||||
|
||||
## 応用原理付録(仕組みを本当に理解したい人向け)
|
||||
|
||||
### 1. バケット分け機構(引き出しの本質)
|
||||
|
||||
Codex のセッション再開 / 履歴リストは、現在アクティブな `model_provider` id で **厳密な文字列フィルタリング** を行います。セッションファイル `.jsonl` の **1 行目** は `type:"session_meta"` のレコードで、その `payload.model_provider` がそのセッションの属する引き出しです(`grep -rl` はファイル内にそのラベルが 1 回でも出現すればそのファイルをカウントするので、行ごとに解析する必要はありません。旧バージョンでこのフィールドを書き込んでいないセッションは数えられません)。セッション再開リストを実際に駆動するのはインデックス DB `state_5.sqlite` の `threads.model_provider` 列です。公式サブスクリプションは `config.toml` に明示的な `model_provider` がないとき、内蔵のデフォルト id `openai` に入ります。CC Switch のすべてのサードパーティプロバイダーは一律に `custom` を使います。
|
||||
|
||||
### 2. スイッチがすること(注入、live にのみ存在)
|
||||
|
||||
オンにすると、CC Switch は公式 live `config.toml` に次の内容を注入します。
|
||||
|
||||
```toml
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "OpenAI"
|
||||
requires_openai_auth = true
|
||||
supports_websockets = true
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
各フィールドには役割があります。`requires_openai_auth = true` は認証を引き続き `auth.json` 内の ChatGPT ログインで行わせ、base_url 未指定時は公式 Codex バックエンドへフォールバックさせます。`name = "OpenAI"` は Codex の公式機能ゲート(web search、リモート圧縮など)を引き続きヒットさせます。`supports_websockets = true` は custom エントリでデフォルトに失われる能力を補います。`wire_api = "responses"` は公式の responses プロトコルを使います。**正味の効果は: 認証は変わらず、バケット名が変わるだけ。**
|
||||
|
||||
**重要な不変条件: この注入は live `config.toml` にのみ存在でき、決してデータベースの保存設定には書き込まれません。** 公式プロバイダーから切り替えて離れ、live をデータベースへ書き戻すとき、CC Switch はこの注入を正確に剥離します(形態が注入物と完全に一致するときだけ剥離し、サードパーティがカスタムした `custom` テーブルはそのまま保持します)。だからこそ「スイッチをオフにする + 一度切り替える」だけで live を完全に復元でき、データベースには常にあなた本来のクリーンな公式設定が保たれます——これがスイッチ全体の可逆性の礎です。
|
||||
|
||||
### 3. 注入の 2 つの拒否ゲート(シナリオ C に対応)
|
||||
|
||||
- `config.toml` に明示的な `model_provider` がすでにある → ユーザーのルーティングを上書きしない。
|
||||
- 形態の異なる `[model_providers.custom]` テーブルがすでに存在する(サードパーティの `base_url` が付いている可能性がある)→ 注入を拒否、さもないと ChatGPT OAuth トラフィックを誤ったバックエンドへルーティングしてしまう。
|
||||
|
||||
注入を拒否したとき live は統一されず、移行ゲート(live の `model_provider` が trim 後に `custom` と等しいかを確認)が `live_not_unified` と判定 → 移行をスキップし、意思を保持し、次回起動の再試行時に行います。これは「安全な先送り」であり、「失敗してデータが消えた」ではありません。
|
||||
|
||||
### 4. セッションの三分類(移行 / 復元の境界を決める)
|
||||
|
||||
- **A 類**: オン時に移行した既存の公式セッション——バックアップが台帳であり、正確に `openai` へ復元可能。
|
||||
- **B 類**: オン期間中に新規作成——どのバックアップにもなく、公式 / サードパーティを判別不能、**決して自動で動かさない**(`custom` に残す)。
|
||||
- **C 類**: オン前の純粋なサードパーティ履歴——絶対に触れない。
|
||||
|
||||
### 5. 移行 / 復元の安全性(データが本当に削除されることはない、その保証はどこから来るか)
|
||||
|
||||
4 層の設計が共同で保証します: **正常・異常のあらゆるパス** において、元のセッションデータが本当に削除されることはありません。
|
||||
|
||||
- **フィールドだけ変更、本文には触れない**: 移行 / 復元はセッションメタデータ内の `model_provider` の値を `openai` と `custom` の間で切り替えるだけで、会話内容、`response_item`、`encrypted_content` はすべてそのまま保持します。
|
||||
- **書き換え前に必ずコピーをバックアップ**: jsonl はファイルコピー、state DB は SQLite の完全なコピーで、タイムスタンプ付きの世代ディレクトリに保存します。移行バックアップは `codex-official-history-unify-v1/` に、復元バックアップは独立した `codex-official-history-unify-restore-v1/` にあり、台帳を純粋に保つため両者は分けられています。
|
||||
- **移すだけ削除しない + 原子書き込み**: すべての jsonl 書き換えは「一時ファイル + 全体置換」を経由し、state DB はトランザクション化された `UPDATE` を経由し、全工程でセッションやインデックスを削除する操作は一切ありません。ファイルはいかなる時点でも完全です。
|
||||
- **悲観的スキップ + 冪等で再試行可能**: バケットが不一致のとき(`live_not_unified`)は移行しないことを選びます。一つのプロセスロックが移行と復元を直列化し、「起動時の再試行 / 保存後のバックグラウンドタスク / 無効化時の復元」が同じ一群のファイルを並行して双方向に書き換えるのを防ぎます。完了マークは Codex ディレクトリに紐づけて条件付きで書き込み、移行漏れを防ぎます。復元は「台帳にある + 現在もまだ custom」の二重条件を使い、誤変更を防ぎます。復元スキャンはすべてのバックアップ世代の和集合を取り、何度もスイッチを切り替えた後でも初期に移行したセッションを復元できます。重複した復元は `nothing_to_restore` を返しますが、これは冪等保護であり失敗ではありません。
|
||||
|
||||
### 6. バックエンドをまたいだ encrypted_content(シナリオ B に対応)
|
||||
|
||||
セッション内の推論暗号文は、それを生成したバックエンドだけが復号でき、上流の Codex は by design でバックエンドをまたいだ復号をサポートしません。これが「再開失敗」の根本原因であり、ファイルの完全性とは無関係です——セッション `.jsonl` は完全にディスク上に横たわり、`encrypted_content` も無傷です。元のプロバイダーに戻して再開するか、新規セッションを始めれば、どちらも正常です。
|
||||
|
||||
---
|
||||
|
||||
## 参考リンク
|
||||
|
||||
- [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する: CC Switch 設定ガイド](./codex-official-auth-preservation-guide-ja.md)
|
||||
- [Codex で DeepSeek などの Chat 形式 API を使う: CC Switch ローカルルーティングガイド](./codex-deepseek-routing-guide-ja.md)
|
||||
- CC Switch ユーザーマニュアル内の「Codex アプリ拡張」関連の章
|
||||
|
||||
---
|
||||
|
||||
**最後に一言**: あなたが見た「セッションが消えた / 再開失敗」は、本質的には **セッションが別の履歴リスト(引き出し)に移されたか、相手のバックエンドが古い推論内容を復号できない** ことであり、ファイルは常にそっくりそのまま `~/.codex/sessions/`(および `state_5.sqlite`)に横たわっています。スイッチをオフにするとき「バックアップから復元する」にチェックを入れれば、移行した公式セッションを正確に公式リストへ戻せます。たとえ復元しなくても、元の `.jsonl` ファイルと `~/.cc-switch/backups/codex-official-history-unify-*/` 配下のバックアップコピーはどちらも残っています——**データが本当に失われることは決してありません。**
|
||||
@@ -0,0 +1,467 @@
|
||||
# 统一 Codex 会话历史:功能介绍与使用攻略(CC Switch)
|
||||
|
||||
> 适用版本:CC Switch v3.16.x 及以上。本文根据当前代码整理,命令与路径均可亲手验证;示例使用去敏数据,不包含真实会话内容或 API Key。
|
||||
|
||||
## 这个功能是什么
|
||||
|
||||
「统一 Codex 会话历史」是 CC Switch v3.16.x 为 Codex 新增的一个开关。它的位置在 **设置 → 通用 → 「Codex 应用增强」分组**里("Codex 应用增强"是这个分组的标题,开关本身叫"统一 Codex 会话历史")。开启后,**官方订阅(ChatGPT 登录 / OpenAI API Key)的会话,会和 CC Switch 管理的所有第三方供应商会话,出现在同一个历史 / 续聊列表里**——不再被分隔在两个互相看不见的列表中。
|
||||
|
||||
## 它解决什么问题
|
||||
|
||||
Codex 自己按"供应商标签"(一个叫 `model_provider` 的字段)给会话分类,而且**续聊 / 历史列表只显示和你当前激活的供应商同标签的会话**。于是会话天然被分进两个"抽屉":
|
||||
|
||||
- 官方订阅的会话,归在 Codex 内建的 **`openai`** 标签下;
|
||||
- CC Switch 管理的所有第三方供应商,归在 **`custom`** 标签下。
|
||||
|
||||
两个抽屉互相看不见。如果你**经常在官方与第三方之间切换**,就会遇到这种割裂:"刚才用官方聊的会话,切到第三方后在历史列表里找不到了"——它其实没丢,只是被分到了另一个抽屉。这种割裂既容易让人误以为会话丢失,也不方便把所有会话放在一处统一回顾、续聊。
|
||||
|
||||
**这个开关就是为了消除这种割裂**:让官方订阅也以 `custom` 标签运行,于是官方与第三方会话合并进同一个列表,找起来、续起来都在一处。
|
||||
|
||||
> ✅ **一个贯穿全文的重要前提,请先记住**:这个功能(统一 / 迁移 / 还原)**全程只改写会话记录里那一个归类标签 `model_provider`,而且每次改写前都会自动把原文件备份一份**。它不会删除、清空或覆盖你的任何一句对话。所以本文后面若提到"某些会话看不到了",几乎都是"被分到了另一个抽屉",而不是"数据没了"——真担心时,直接看 [症状对照表](#我感觉会话丢了症状对照表) 与 [亲手验证文件还在](#亲手验证你的会话文件还在硬盘上最重要的一节)。
|
||||
|
||||
## 工作原理(一句话版)
|
||||
|
||||
把它想成 **两个抽屉 + 自动备份**:
|
||||
|
||||
- 默认时,官方会话在 `openai` 抽屉、第三方会话在 `custom` 抽屉,互不可见;
|
||||
- 开关让**官方也改用 `custom` 抽屉**,于是两个抽屉合并成一个共享列表;
|
||||
- 你可以选择把**现有的官方老会话**也一并"搬"进共享抽屉(这一步叫**迁移**,可选、需主动勾选),而**任何搬动前都会先复制一份备份**,所以整个过程**可逆**;
|
||||
- **认证完全不受影响**——官方订阅照常用你的 ChatGPT 登录、照常走官方后端,变的只是会话的归类标签。
|
||||
|
||||
完整机制(注入了什么、为什么可逆、迁移/还原如何保证不丢数据)见下文 [核心心智模型](#核心心智模型两个抽屉--自动备份) 与文末 [进阶原理附录](#进阶原理附录给想真正搞懂机制的用户)。
|
||||
|
||||
## 如何使用(速览)
|
||||
|
||||
1. **开启**:设置 → 通用 → Codex 应用增强 → 打开「统一 Codex 会话历史」→ 在弹窗里决定是否勾选"同时迁入现有官方会话历史"(想让**以前**的官方会话也并进统一列表,就勾上;只想从现在起统一,就不勾)→ 确认。详见 [开启时会发生什么](#开启时会发生什么分步说明)。
|
||||
2. **关闭**:关掉同一开关 → 弹窗里保持勾选"按备份精确还原"(默认就勾着)→ 确认,即可把当初迁入的官方会话精确翻回官方列表。详见 [关闭时会发生什么](#关闭时会发生什么分步说明)。
|
||||
3. **感觉会话丢了?** 别慌,跳到 [症状对照表](#我感觉会话丢了症状对照表) 按症状定位,并用 [亲手验证](#亲手验证你的会话文件还在硬盘上最重要的一节) 一节的命令亲眼确认文件都在。
|
||||
|
||||
---
|
||||
|
||||
## 核心心智模型:两个抽屉 + 自动备份
|
||||
|
||||
要理解这个功能,你只需要记住两件事:**抽屉**和**备份**。
|
||||
|
||||
### 抽屉:Codex 怎么给会话分类
|
||||
|
||||
你每开一个 Codex 会话,Codex 会在会话文件头部记一个标签 `model_provider`,标记"这条会话是用哪个供应商聊的"。Codex 的**续聊 / 历史列表是按当前激活的这个标签精确过滤的**——只显示和"你现在这个供应商"同标签的会话。
|
||||
|
||||
- 官方订阅(ChatGPT 登录 / OpenAI API Key)的会话,标签是内建的 **`openai`**。
|
||||
- CC Switch 管理的所有第三方供应商,统一用标签 **`custom`**。
|
||||
|
||||
所以默认情况下,官方会话和第三方会话天生互相看不见——它们在两个不同的抽屉里。这是 **Codex 自身的设计**,不是 CC Switch 弄丢了什么。
|
||||
|
||||
```text
|
||||
默认状态(没开统一开关):
|
||||
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ openai 抽屉 │ │ custom 抽屉 │
|
||||
│ (官方订阅会话) │ │ (第三方供应商会话)│
|
||||
└─────────────────┘ └─────────────────┘
|
||||
▲ ▲
|
||||
用官方时只看到这边 用第三方时只看到这边
|
||||
(两个抽屉互相看不见)
|
||||
```
|
||||
|
||||
**「统一 Codex 会话历史」开关做的事,就是让官方订阅也以 `custom` 标签运行,把两个抽屉合并成一个**,于是官方会话和第三方会话出现在同一个续聊列表里。注意:**认证没变**——你的官方订阅照常用你的 ChatGPT 登录、照常走官方后端,只是会话的"归类标签"从 `openai` 变成了 `custom`。
|
||||
|
||||
```text
|
||||
开启统一开关后:
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ custom 共享抽屉 │
|
||||
│ 官方订阅会话 + 第三方供应商会话 │
|
||||
│ (出现在同一个历史 / 续聊列表里) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 备份:每次改标签前都先复制一份
|
||||
|
||||
"合并抽屉"需要把一部分官方会话的标签从 `openai` 改成 `custom`(这一步叫**迁移**,且是**可选的、需要你主动勾选**)。而**任何一次改写之前,CC Switch 都会先把原文件原封不动地复制一份**到这里:
|
||||
|
||||
```text
|
||||
~/.cc-switch/backups/codex-official-history-unify-v1/<时间戳>/
|
||||
```
|
||||
|
||||
这份备份,就是日后"按备份精确还原"的唯一依据。它让整个过程变得**可逆**:你随时可以关掉开关,把当初迁进来的官方会话精确地翻回 `openai` 抽屉。
|
||||
|
||||
记住这两个词——**抽屉**(会话只是换了归类)、**备份**(改前必先复制)——后面所有内容你都能轻松理解。
|
||||
|
||||
---
|
||||
|
||||
## 开启时会发生什么:分步说明
|
||||
|
||||
### 第 1 步:找到开关
|
||||
|
||||
```text
|
||||
设置 → 通用 → Codex 应用增强
|
||||
```
|
||||
|
||||
在"Codex 应用增强"这个区块里有两行开关,**第二行**(蓝色历史图标)就是本攻略的主角:
|
||||
|
||||
> **统一 Codex 会话历史**
|
||||
|
||||
它下方有一段说明文字(逐字):
|
||||
|
||||
> 开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败
|
||||
|
||||
> **注意**:这一句说明里已经预告了三件事——会出现在同一列表、可选迁入并自动备份、跨供应商续聊"可能继续失败"。这里的"继续失败"指的是**续不上、生成不了新回合**,不是"记录丢失"。这正是后面要重点拆解的核心误解。
|
||||
|
||||
### 第 2 步:把开关从关拨到开 → 弹出确认窗
|
||||
|
||||
一旦你把开关拨到开,CC Switch **不会立刻保存**,而是先弹出一个确认窗口。窗口文案如下(逐字):
|
||||
|
||||
- **标题**:统一 Codex 会话历史
|
||||
- **正文**:
|
||||
|
||||
> 开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。
|
||||
>
|
||||
> 可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。
|
||||
|
||||
- **复选框**:同时迁入现有官方会话历史
|
||||
- **确认按钮**:我已了解,继续开启
|
||||
- **取消按钮**:取消
|
||||
|
||||
**这个复选框默认是不勾选的。** 这是一个重要的分岔点:
|
||||
|
||||
| 你的选择 | 效果 | 此刻你的数据在哪 |
|
||||
|---|---|---|
|
||||
| **不勾**(默认) | 只切换标识。**只有开启之后新建的官方会话**才会落进 `custom` 共享抽屉 | 你**开启前**的官方老会话,标签仍是 `openai`,原地未动,仍在 `~/.codex/sessions/` |
|
||||
| **勾上** | 除了切换标识,还会把**现有的官方老会话**也从 `openai` 抽屉迁进 `custom` 抽屉 | 老会话被**复制备份**后,标签改写为 `custom`;原始数据有备份兜底 |
|
||||
|
||||
> **如果你希望"以前的官方会话也出现在统一列表里",必须主动勾选这个复选框。** 否则你会遇到下面对照表里的"场景 A"——老会话看起来"不见了",其实只是留在原抽屉里。
|
||||
|
||||
点"取消"或点窗口外面:开关直接弹回关闭状态,什么都没发生。
|
||||
点"我已了解,继续开启":开关保存为开启,CC Switch 在后台落盘配置(如果勾了迁移,就执行迁移)。
|
||||
|
||||
### 第 3 步(仅当勾了迁移):迁移如何执行 + 数据安全
|
||||
|
||||
如果你勾了"同时迁入现有官方会话历史",CC Switch 会对你的官方老会话做这套流程:
|
||||
|
||||
```text
|
||||
对每个官方(openai 标签)会话文件:
|
||||
① 先把原文件原样复制一份到备份目录 ← 数据有了第一道保险
|
||||
② 用「写临时文件 → 整体替换」的原子方式,
|
||||
只把头部那行 session_meta 里的 model_provider
|
||||
从 "openai" 改成 "custom" ← 对话正文一个字节都不动
|
||||
③ 索引数据库 state_5.sqlite 同步在一个事务里把标签改过来
|
||||
```
|
||||
|
||||
- **备份位置**:`~/.cc-switch/backups/codex-official-history-unify-v1/<时间戳>/`,每次迁移生成一个带时间戳的"代际目录",内含 `jsonl/`(会话副本)、`state/`(索引库副本)、`meta.json`(记录这次迁移属于哪个 Codex 目录)。
|
||||
- **改的是什么**:只有 `model_provider` 这一个字段值。你的对话内容、推理内容、所有正文**原样保留**。
|
||||
- **删的是什么**:**什么都没删**。备份是"复制",改写是"原子替换同一个文件",全程没有任何删除会话或索引的动作。文件在任何时刻都是完整的(要么是旧内容、要么是新内容,绝不会是空或半截)。
|
||||
|
||||
迁移成功后,这些官方老会话就出现在统一列表里了。**此刻你的数据**:① 原始副本在备份目录;② 活动文件里只有归类标签变了,内容完好。
|
||||
|
||||
> **注意**:开启与迁移本身**不会弹成功提示**。迁移是后端在保存时顺带跑的,UI 上你只会看到开关变成了打开状态。所以"没看到迁移成功的弹窗"是正常的,不代表失败。
|
||||
|
||||
---
|
||||
|
||||
## 关闭时会发生什么:分步说明
|
||||
|
||||
### 第 1 步:把开关从开拨到关 → 探测备份 → 弹出确认窗
|
||||
|
||||
关闭时,CC Switch 会**先花一瞬间探测有没有迁移备份**,然后弹出确认窗口(所以关闭弹窗会有一点点延迟,属正常)。文案如下(逐字):
|
||||
|
||||
- **标题**:关闭统一会话历史
|
||||
- **正文**:
|
||||
|
||||
> 关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。
|
||||
|
||||
- **复选框**(条件显示):把开启时迁入的官方会话还原回官方历史(按备份精确还原)
|
||||
- **确认按钮**:关闭
|
||||
- **取消按钮**:取消
|
||||
|
||||
> **划重点**:正文说的是"官方订阅**将看不到它们**"——是**看不到**,不是**删除**。开启期间你新聊的会话仍然完整地在 `custom` 抽屉里,只是关闭后官方那一侧看不到而已。
|
||||
|
||||
**这个还原复选框默认是勾选的。** 也就是说,默认行为就是"关闭的同时,把当初迁入的官方会话精确还原回官方历史"。你只要保持勾选、点"关闭"即可。
|
||||
|
||||
如果复选框**没有出现**,说明系统判断当前没有需要还原的备份(要么你从没勾过迁移、要么探测不到备份)——这种情况下你的官方老会话从没被改动过,关掉开关它们自己就回到 `openai` 抽屉了。
|
||||
|
||||
### 第 2 步:还原如何执行(按备份账本精确翻回)
|
||||
|
||||
如果你保持勾选并点"关闭",CC Switch 的还原流程是这样的:
|
||||
|
||||
```text
|
||||
① 先把当前现场再复制一份到独立的还原备份目录
|
||||
~/.cc-switch/backups/codex-official-history-unify-restore-v1/<时间戳>/
|
||||
(还原本身也先备份,所以还原也不会丢数据)
|
||||
② 翻遍所有迁移备份代际,找出"当初标签是 openai"的会话 id,组成一份"账本"
|
||||
③ 只对【既在账本里、当前又仍是 custom】的会话,把标签改回 "openai"
|
||||
```
|
||||
|
||||
注意第 ③ 步的**双重条件**——既要在账本里(证明它当初确实是官方迁来的),又要当前仍是 `custom`(说明你没手动改过它)。两个条件都满足才翻回。这保证了还原既精确又不会误伤。
|
||||
|
||||
**此刻你的数据**:被迁回的官方会话标签改回 `openai`,重新出现在官方列表;同时迁移备份和还原备份两份副本都还在硬盘上。
|
||||
|
||||
### 第 3 步:看提示,确认结果
|
||||
|
||||
只有"关闭 + 勾选还原"这条路径会弹结果提示。可能看到的提示(逐字):
|
||||
|
||||
| 你看到的提示 | 含义 |
|
||||
|---|---|
|
||||
| **已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)** | 还原成功。`{{files}}` / `{{rows}}` 处会显示实际数字 |
|
||||
| **当前 Codex 目录没有可恢复的迁移备份** | 没有可还原的内容(**不等于数据丢了**,详见对照表场景 E) |
|
||||
| **统一会话历史开关已重新开启,已跳过还原** | 还原排队期间你又把开关打开了,系统主动放弃还原(详见对照表场景 F) |
|
||||
| **还原官方会话历史失败,请重试** | 还原过程报错,重试即可,数据未被破坏 |
|
||||
| **保存失败,请重试** | 关闭这一步保存本身就失败了;此时**绝不会触发还原**,开关弹回原位 |
|
||||
|
||||
> **一个贴心的安全设计**:如果"关闭开关"这一步保存失败,CC Switch **绝不会去执行还原**。否则就会出现"开关还开着、会话却被翻回 openai 桶"的撕裂状态。保存失败时开关会**自动弹回原来的位置**,你不会停留在一个"看起来已关、实则没保存"的假状态里。
|
||||
|
||||
---
|
||||
|
||||
## "我感觉会话丢了?"症状对照表
|
||||
|
||||
下面六个场景,是用户最容易误以为"会话丢了"的情形。**每一个的真相都是:数据完好,只是换了抽屉或暂时看不到。** 先用这张表按症状定位,再看下面的详细说明。
|
||||
|
||||
| 场景 | 你看到的 | 数据真相 | 一句话解法 |
|
||||
|---|---|---|---|
|
||||
| **A** 没勾迁移 | 官方老会话不在统一列表 | 全在,仍带 `openai` 标签 | 重开并勾迁移,或关开关 |
|
||||
| **B** 跨供应商续聊失败 | 续不上 / 报错 | 文件完好,只是密文跨后端解不开 | 回原供应商续;只看内容直接读 jsonl |
|
||||
| **C** 代理接管 / 注入被拒 | 没迁也没还原 | 迁移被安全跳过,文件没动 | 退出接管 → 重启重试;或直接关开关 |
|
||||
| **D** 还原后新会话没回官方 | 开启期间新会话不在官方 | 在 `custom` 抽屉,设计上不动 | 切第三方供应商即可见 |
|
||||
| **E** 提示"没有可恢复备份" | 还原"失败" | 通常压根没迁移过,会话在原抽屉 | 关开关官方会话自动复现 |
|
||||
| **F** 提示"开关已重新开启,跳过还原" | 还原被拒 | 防数据撕裂,啥也没改 | 先彻底关开关再还原 |
|
||||
|
||||
### 场景 A:开了开关但没勾迁移 → 官方老会话"不见了"
|
||||
|
||||
**现象**:你开了统一开关,但开启弹窗里那个"同时迁入现有官方会话历史"没勾(它默认就不勾)。开启后一看,以前的官方老会话好像都不在列表里了。
|
||||
|
||||
**真相**:数据 100% 都在,一行都没动。开关只对"开启之后新建"的官方会话生效,你**开启前**的官方老会话标签仍是 `openai`,原封不动地躺在 `~/.codex/sessions/` 里。你现在激活的是 `custom` 抽屉,自然看不到留在 `openai` 抽屉里的老会话——这就是"看起来消失"的全部原因。
|
||||
|
||||
**怎么办**(任选其一):
|
||||
1. **重新开启开关时勾上"同时迁入现有官方会话历史"**,把老会话换到 `custom` 抽屉,它们立刻出现在统一列表(改写前自动备份)。
|
||||
2. **或者干脆关掉统一开关**,官方重新以 `openai` 抽屉运行,老会话原地复现。
|
||||
|
||||
### 场景 B:跨供应商续聊旧会话失败 → 以为"这条会话坏了 / 没了"
|
||||
|
||||
**现象**:统一之后列表里能看到一条用"另一家供应商"聊出来的旧会话,你切到现在的供应商点"继续",结果报错或接不上。
|
||||
|
||||
**真相**:会话文件完好无损,丢的不是数据,是"跨后端解密能力"。Codex 会话里保存了一段加密的推理内容 `encrypted_content`,**这段密文只有当初生成它的那个后端能解密**。你用 B 供应商去续 A 供应商生成的会话,B 解不开 A 的密文 → 续聊失败。这是**上游 Codex 的设计限制(by design)**,与 CC Switch 是否动过文件无关。会话里的文字内容你随时能读到。
|
||||
|
||||
> 这是整篇攻略里**唯一一个"看起来真出了问题"的真实例外**——但请注意:它只是**无法续聊(生成不了新回合)**,**原始文件依然完整存在**,对话文字随时可读。
|
||||
|
||||
**怎么办**:
|
||||
- **用"当初创建这条会话的那个供应商"去续聊**,就能正常解密、接上。
|
||||
- 只想看历史内容、不必继续?直接读那条会话的 `.jsonl` 文件(文末有命令)。
|
||||
- 经验法则:**跨供应商更适合"开新会话",老会话尽量回原供应商续。**
|
||||
|
||||
### 场景 C:开了开关也勾了迁移,但迁移被静默跳过 → 以为"迁移把会话弄丢了"
|
||||
|
||||
**现象**:你开启并勾了迁移,但官方老会话既没进统一列表、关开关想还原也提示没东西可还原(或者关闭弹窗里压根没出现还原复选框,参见场景 E)。你怀疑迁移过程中把会话搞丢了。
|
||||
|
||||
**真相**:迁移根本**没执行**,所以也不可能弄丢——你的会话一个字都没被改。CC Switch 在迁移前有一道安全闸门:它会检查 Codex 的 live 配置(`~/.codex/config.toml`)此刻是否**真的**路由到了共享 `custom` 抽屉,只有真路由过去了才迁移。以下两种情况会判定"还没统一"(内部原因码 `live_not_unified`),于是**主动跳过迁移、保留你的开关和迁移意愿、等条件满足后再迁**:
|
||||
|
||||
- **代理接管期间**:CC Switch 的代理接管了 live 配置,接管期的 live 不带统一路由标记。
|
||||
- **注入被拒**:你的 `config.toml` 已有手工指定的 `model_provider`,或已存在一张形态不同的 `[model_providers.custom]` 表(可能带第三方地址)。为避免把官方流量错误路由到第三方后端,CC Switch 宁可不注入、不迁移。
|
||||
|
||||
跳过迁移 = 不碰任何会话文件。**没迁,等于没动,谈不上丢。** 这是"安全延后",不是"失败丢数据"。
|
||||
|
||||
**怎么办**:
|
||||
- 退出代理接管 → **重启 CC Switch**:启动时会自动重试迁移(你的迁移意愿一直保留着)。
|
||||
- 检查 `~/.codex/config.toml`:若有你手工写的冲突路由,整理掉冲突后再开开关。
|
||||
- 实在不想折腾:直接关开关,官方会话仍以 `openai` 抽屉正常显示,毫发无损。
|
||||
|
||||
### 场景 D:关了开关并还原,但"开启期间新聊的会话"没回官方 → 以为"新会话丢了"
|
||||
|
||||
**现象**:你开启统一期间,用官方又聊了几条新会话。后来关开关、勾了还原,还原完发现那几条新会话没回到官方抽屉。
|
||||
|
||||
**真相**:这是**有意为之**的设计,新会话好端端在 `custom` 抽屉里,能看见、能续。还原的依据是"迁移时的备份账本"——**只有当初从 `openai` 抽屉迁进来的会话**,备份里有据可查,才会被精确翻回 `openai`。你**开启期间新建**的会话不在任何备份账本里;而且统一之后官方和第三方都用 `custom` 标签,**CC Switch 无法分辨这条新会话到底是官方聊的还是第三方聊的**。为了不把第三方会话误塞进官方历史,产品决策是:这些新会话一律留在 `custom`(第三方)历史里,绝不自动搬动。关闭弹窗的文案也明示了这一点——"开启期间产生的会话因无法区分来源,将留在第三方历史中"。
|
||||
|
||||
**怎么办**:
|
||||
- 切到任意一个第三方供应商(`custom` 抽屉),就能在历史列表里看到这些会话。
|
||||
- 想看内容直接读 `.jsonl`;想续聊遵循场景 B 的规则(回到当初生成它的后端)。
|
||||
- 如果你确实想把**某一条**手动归回官方:目前没有自动按钮(刻意不做,避免误判方向)。进阶用户可在**先备份**该文件后,手动把它 `.jsonl` 第一行 `session_meta` 里的 `model_provider` 从 `custom` 改回 `openai`(属高阶操作,改前务必复制一份)。
|
||||
|
||||
### 场景 E:还原提示"当前 Codex 目录没有可恢复的迁移备份" → 以为"还原失败 = 数据没了"
|
||||
|
||||
**现象**:关开关时勾了还原,结果弹出提示"当前 Codex 目录没有可恢复的迁移备份"。你慌了:还原都失败了,是不是数据彻底没了?
|
||||
|
||||
**真相**:"没有可还原的东西"≠"数据丢了"。恰恰相反,通常是因为**根本没有需要还原的迁移**。常见原因:
|
||||
|
||||
- **你当初没勾过"迁入现有官方会话"**:既然没迁移,自然没有迁移备份、也没有需要翻回去的会话。你的官方老会话一直在 `openai` 抽屉,关开关后直接复现(同场景 A)。(这种情况下,关闭弹窗甚至可能**根本不显示还原复选框**——因为系统探测不到任何备份。)
|
||||
- **已经还原过一遍了**:会话标签已全部翻回 `openai`,再点一次自然"没有仍是 custom 的目标可还原"——这是**幂等保护,不是失败**。
|
||||
- **切换过 Codex 目录**:还原只认属于**当前**目录的备份账本,换了目录就找不到旧目录的账本,把目录切回去即可。
|
||||
|
||||
这三种情况下,没有任何会话被删除。
|
||||
|
||||
**怎么办**:用文末命令统计 `~/.codex/sessions/` 里的会话文件总数,确认文件都在;再看 `~/.cc-switch/backups/` 里有没有 `codex-official-history-unify-v1` 目录——如果连这个目录都没有,说明你从没触发过迁移,会话一直在原抽屉。
|
||||
|
||||
### 场景 F:还原被拒,提示"统一会话历史开关已重新开启,已跳过还原"
|
||||
|
||||
**现象**:关开关 → 勾还原 → 你手很快,紧接着又把开关重新打开了,然后看到提示"统一会话历史开关已重新开启,已跳过还原"。
|
||||
|
||||
**真相**:这是一道防护,防止把数据弄成"撕裂"状态,会话同样没丢。还原的动作是"把会话标签从 `custom` 翻回 `openai`",但如果此刻开关又开着,live 配置正路由到 `custom`——一边把历史翻回 `openai`、一边新会话往 `custom` 落,会话会被人为撕成两半。所以 CC Switch 检测到"开关又开了",**主动放弃这次还原、什么都不改**。会话维持现状,没有任何删除或破坏。
|
||||
|
||||
**怎么办**:想真正还原,就**先把开关稳定地关掉**(别再立刻打开),再执行关闭 + 勾还原;想保持统一,就别还原,让会话留在 `custom` 共享抽屉正常使用。
|
||||
|
||||
**总原则:CC Switch 的统一 / 迁移 / 还原全程只改会话的一个标签字段,并且每次改写前都自动备份。它不会删你的对话。看不见 ≠ 丢了——换个抽屉看,或用下面的命令亲眼确认。**
|
||||
|
||||
---
|
||||
|
||||
## 亲手验证:你的会话文件还在硬盘上(最重要的一节)
|
||||
|
||||
文字再多,不如亲眼看见。下面给出**真实路径**(取自 CC Switch 源码)和在不同系统下查看会话文件、备份目录的方法。**全程只读不改,强烈建议你亲手试一遍。**
|
||||
|
||||
### 最简单的方式:用文件管理器直接打开(完全不用命令行)
|
||||
|
||||
- **macOS(Finder)**:按 `Cmd + Shift + G`,粘贴 `~/.codex/sessions` 回车,就能看到一堆 `.jsonl` 会话文件和它们的修改时间;备份目录粘贴 `~/.cc-switch/backups`。
|
||||
- **Windows(文件资源管理器)**:在地址栏粘贴 `%USERPROFILE%\.codex\sessions` 回车,就能看到会话文件夹和里面的 `.jsonl`;备份目录粘贴 `%USERPROFILE%\.cc-switch\backups`。
|
||||
|
||||
**只要你能在这里看到一批 `.jsonl` 文件,就证明会话数据完好无损地在硬盘上。** 文件数量、修改时间,比任何文字都直观。
|
||||
|
||||
### 你的会话 / 历史文件到底在哪
|
||||
|
||||
| 内容 | 真实路径 | 说明 |
|
||||
|---|---|---|
|
||||
| **会话正文(核心)** | `~/.codex/sessions/`(含按日期分的子目录,递归) | 每个会话一个 `.jsonl` 文本文件,**这就是你的对话内容** |
|
||||
| **归档会话** | `~/.codex/archived_sessions/` | 同为 `.jsonl` |
|
||||
| **会话索引数据库** | `~/.codex/state_5.sqlite` | `threads` 表的 `model_provider` 列就是"抽屉标签",**它才是续聊列表真正读取的归类来源** |
|
||||
| **迁移备份**(开启迁移时自动产生) | `~/.cc-switch/backups/codex-official-history-unify-v1/<时间戳>/` | 内含 `jsonl/`、`state/`、`meta.json` |
|
||||
| **还原备份**(点还原时自动产生) | `~/.cc-switch/backups/codex-official-history-unify-restore-v1/<时间戳>/` | 还原前的安全副本 |
|
||||
|
||||
> **注意**:如果你在 CC Switch 里改过 Codex 目录,或在 `config.toml` 里设了 `sqlite_home`,请把上面的 `~/.codex` 换成你的实际目录。下文 `~` = 你的用户主目录。
|
||||
|
||||
### macOS / Linux 命令
|
||||
|
||||
**1. 数会话文件总数(这才是"没丢"的硬证据)**
|
||||
|
||||
```bash
|
||||
# 统计会话文件总数 —— 只要这个数字符合你的预期,数据就都在
|
||||
find ~/.codex/sessions ~/.codex/archived_sessions -name '*.jsonl' 2>/dev/null | wc -l
|
||||
|
||||
# 看最近修改的 10 个会话文件
|
||||
find ~/.codex/sessions -name '*.jsonl' 2>/dev/null -print0 \
|
||||
| xargs -0 ls -lt 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
**2. (辅助)看每个"抽屉"各有多少会话**
|
||||
|
||||
```bash
|
||||
# 官方抽屉(openai)会话文件数
|
||||
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"openai"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||
|
||||
# 统一抽屉(custom)会话文件数
|
||||
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"custom"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||
|
||||
# 看各标签分布一目了然
|
||||
grep -rhoE '"model_provider"[[:space:]]*:[[:space:]]*"[^"]*"' ~/.codex/sessions 2>/dev/null | sort | uniq -c
|
||||
```
|
||||
|
||||
> **重要提示,别被这一步吓到**:**早期版本的 Codex 不在 `.jsonl` 里写 `model_provider` 字段**,这些旧官方会话用上面的 grep 是**数不到**的,但它们在索引库 `state_5.sqlite` 里仍然归类为 `openai`、续聊列表照样能看到。所以**判断"会话没丢"请以第 1 步的文件总数为准**——分桶 grep 只是帮你理解归类,数出来比文件总数少**完全正常**,绝不代表"丢了一批"。
|
||||
|
||||
**3. (进阶)查索引库 `state_5.sqlite`——续聊列表真正读的归类**
|
||||
|
||||
```bash
|
||||
# 需要已安装 sqlite3;没装可跳过
|
||||
sqlite3 ~/.codex/state_5.sqlite \
|
||||
"SELECT COALESCE(model_provider,'<空>'), COUNT(*) FROM threads GROUP BY 1;"
|
||||
```
|
||||
|
||||
> 这张 `threads` 表才是 Codex 续聊列表真正读取的归类来源,`openai` 行数 ≈ 你官方抽屉里能看到的会话数。它和第 2 步的 jsonl grep 可能对不上数——原因就是上面说的"旧会话不写 jsonl 字段,但索引库里仍是 openai"。两边对不上不是异常。
|
||||
|
||||
**4. 直接读某条会话的内容(确认对话文字还在)**
|
||||
|
||||
```bash
|
||||
# 把 <文件名> 换成上面 ls 列出的某个 .jsonl 路径
|
||||
python3 -m json.tool < "<文件名>.jsonl" 2>/dev/null | head -50
|
||||
|
||||
# 或者直接用编辑器打开看(纯文本)
|
||||
open -e "<文件名>.jsonl" # macOS
|
||||
```
|
||||
|
||||
**5. 看 CC Switch 的备份目录(证明迁移 / 还原前都留了副本)**
|
||||
|
||||
```bash
|
||||
ls -la ~/.cc-switch/backups/codex-official-history-unify-v1/ 2>/dev/null
|
||||
ls -la ~/.cc-switch/backups/codex-official-history-unify-restore-v1/ 2>/dev/null
|
||||
```
|
||||
|
||||
### Windows 命令(PowerShell)
|
||||
|
||||
会话目录通常在 `C:\Users\<你的用户名>\.codex\`,备份在 `C:\Users\<你的用户名>\.cc-switch\backups\`。
|
||||
|
||||
```powershell
|
||||
# 1. 会话文件总数("没丢"的硬证据)
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions","$env:USERPROFILE\.codex\archived_sessions" -Recurse -Filter *.jsonl -ErrorAction SilentlyContinue).Count
|
||||
|
||||
# 2. 最近修改的 10 个会话
|
||||
Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 10 FullName,LastWriteTime
|
||||
|
||||
# 3. (辅助)官方(openai) / 统一(custom) 抽屉各多少会话文件
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Select-String -Pattern 'model_provider"\s*:\s*"openai"' -List).Count
|
||||
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||
Select-String -Pattern 'model_provider"\s*:\s*"custom"' -List).Count
|
||||
|
||||
# 4. 看备份目录
|
||||
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-v1" -ErrorAction SilentlyContinue
|
||||
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-restore-v1" -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
> 同样提醒:第 3 步的 grep 数会**少于**文件总数属正常(旧会话不写该字段),请以第 1 步的**文件总数**作为"会话没丢"的判断依据。
|
||||
|
||||
---
|
||||
|
||||
## 进阶原理附录(给想真正搞懂机制的用户)
|
||||
|
||||
### 1. 分桶机制(抽屉的本质)
|
||||
|
||||
Codex 的续聊 / 历史列表按当前激活的 `model_provider` id **精确字符串过滤**。会话文件 `.jsonl` 的**第一行**是一条 `type:"session_meta"` 记录,其 `payload.model_provider` 即该会话所属抽屉(`grep -rl` 只要文件里出现一次该标签就计入该文件,因此无需逐行解析;旧版本未写该字段的会话则数不到)。真正驱动续聊列表的是索引库 `state_5.sqlite` 的 `threads.model_provider` 列。官方订阅在 `config.toml` 没有显式 `model_provider` 时落进内建默认 id `openai`;CC Switch 的所有第三方供应商统一用 `custom`。
|
||||
|
||||
### 2. 开关做的事(注入,只活在 live)
|
||||
|
||||
开启后,CC Switch 对官方 live `config.toml` 注入如下内容:
|
||||
|
||||
```toml
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "OpenAI"
|
||||
requires_openai_auth = true
|
||||
supports_websockets = true
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
每个字段都有作用:`requires_openai_auth = true` 让认证继续走 `auth.json` 里的 ChatGPT 登录、base_url 缺省回落官方 Codex 后端;`name = "OpenAI"` 让 Codex 的官方特性门控(web search、远程压缩等)继续命中;`supports_websockets = true` 补回 custom 条目默认丢失的能力;`wire_api = "responses"` 用官方 responses 协议。**净效果是:认证没变,只是桶名变了。**
|
||||
|
||||
**关键不变量:这段注入只能存在于 live `config.toml`,绝不写进数据库的存储配置。** 切换离开官方供应商、把 live 回写数据库时,CC Switch 会把这段注入精确剥离(只在形态与注入产物完全一致时才剥,第三方自定义的 `custom` 表原样保留)。正因如此,"关掉开关 + 切换一次"就能彻底还原 live,数据库里始终是你原本干净的官方配置——这是整个开关可逆性的基石。
|
||||
|
||||
### 3. 注入的两道拒绝闸(对应场景 C)
|
||||
|
||||
- `config.toml` 已有显式 `model_provider` → 不覆盖用户路由;
|
||||
- 已存在形态不同的 `[model_providers.custom]` 表(可能带第三方 `base_url`)→ 拒绝注入,否则会把 ChatGPT OAuth 流量路由到错误后端。
|
||||
|
||||
拒绝注入时 live 不统一,迁移闸门(检查 live 的 `model_provider` 是否 trim 后等于 `custom`)判定 `live_not_unified` → 跳过迁移、保留意愿、等下次启动重试时再做。这是"安全延后",不是"失败丢数据"。
|
||||
|
||||
### 4. 会话三分类(决定迁移 / 还原边界)
|
||||
|
||||
- **A 类**:开启时迁入的存量官方会话——备份即账本,可精确还原回 `openai`;
|
||||
- **B 类**:开启期间新建——不在任何备份、官方 / 第三方不可分,**永不自动搬动**(留 `custom`);
|
||||
- **C 类**:开启前的纯第三方历史——绝不触碰。
|
||||
|
||||
### 5. 迁移 / 还原的安全性(数据不会被真正删除,保障来自哪里)
|
||||
|
||||
四层设计共同保证:在**正常与异常的所有路径**下,原始会话数据都不会被真正删除。
|
||||
|
||||
- **只改字段,不动正文**:迁移 / 还原只把会话元数据里的 `model_provider` 值在 `openai` 与 `custom` 之间切换,对话内容、`response_item`、`encrypted_content` 一律原样保留。
|
||||
- **改写前必先复制备份**:jsonl 用文件复制、state DB 用 SQLite 完整副本,存进时间戳代际目录。迁移备份在 `codex-official-history-unify-v1/`,还原备份在独立的 `codex-official-history-unify-restore-v1/`,两者分开以保持账本纯净。
|
||||
- **只移不删 + 原子写**:所有 jsonl 改写走"临时文件 + 整体替换",state DB 走事务化 `UPDATE`,全程没有任何删除会话或索引的动作。文件在任一时刻都是完整的。
|
||||
- **悲观跳过 + 幂等可重试**:桶不一致时(`live_not_unified`)宁可不迁;一把进程锁串行化迁移与还原,避免"启动重试 / 保存后台任务 / 关闭还原"并发对同批文件双向改写;完成标记按 Codex 目录绑定、条件写入,防漏迁;还原用"在账本 + 当前仍 custom"双重条件,防误改。还原扫描全部备份代际取并集,多次开关循环后仍能还原早期迁入的会话;重复还原返回 `nothing_to_restore`,是幂等保护而非失败。
|
||||
|
||||
### 6. 跨后端 encrypted_content(对应场景 B)
|
||||
|
||||
会话内的推理密文只能被生成它的后端解密,上游 Codex by design 不支持跨后端解密。这是"续聊失败"的根因,与文件完整性无关——会话 `.jsonl` 完整躺在磁盘上、`encrypted_content` 也完好无损。换回原供应商续聊,或开新会话,都正常。
|
||||
|
||||
---
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [使用第三方 API 时保留 Codex 远程操作和官方插件:CC Switch 配置攻略](./codex-official-auth-preservation-guide-zh.md)
|
||||
- [在 Codex 中使用 DeepSeek 这类 Chat 格式 API:CC Switch 路由攻略](./codex-deepseek-routing-guide-zh.md)
|
||||
- CC Switch 用户手册中「Codex 应用增强」相关章节
|
||||
|
||||
---
|
||||
|
||||
**给你的最后一句话**:你看到的"会话不见了 / 续聊失败",本质是**会话被换到了另一个历史列表(抽屉)里、或对方后端无法解密旧推理内容**,文件始终原封不动地躺在 `~/.codex/sessions/`(及 `state_5.sqlite`)里。关闭开关时勾选"按备份还原"即可把当初迁入的官方会话精确翻回官方列表;即便不还原,原始 `.jsonl` 文件和 `~/.cc-switch/backups/codex-official-history-unify-*/` 下的备份副本也都在——**数据绝不会真正丢失。**
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
## Usage Guides
|
||||
|
||||
This release changes how usage is counted and reworks the dashboard quite a bit, so it is worth starting here:
|
||||
This release adds a **Codex unified session history** toggle — it migrates / restores sessions, and if used without care it can make you think sessions were "lost," so it is well worth reading its guide first. This release also changes how usage is counted and reworks the dashboard quite a bit, so both are worth starting with:
|
||||
|
||||
- **[Codex Unified Session History: Feature Overview and Usage Guide](../guides/codex-unified-session-history-guide-en.md)**: what "unify / migrate / restore" actually changes, why your data is never truly lost, and how to verify and precisely restore sessions when you can't see them. **If you used this toggle or worry a session is gone, read this first.**
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources (proxy logs, session sync) and how the statistics are counted. This release adds dashboard-wide provider / model filters and surfaces the real pricing model for route-takeover traffic.
|
||||
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: the custom User-Agent override, the Codex unified session history toggle, and other switches live in the provider form's advanced options and on the settings page.
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースでは使用量統計の数え方とダッシュボードに多くの調整を加えたため、まず以下をご覧ください:
|
||||
本リリースでは **Codex 統一セッション履歴** のトグルを新設しました——セッションの移行 / 復元を伴い、操作を誤ると「セッションが消えた」と誤解しやすいため、まずこのガイドを読むことを強くおすすめします。また使用量統計の数え方とダッシュボードにも多くの調整を加えたので、あわせて以下をご覧ください:
|
||||
|
||||
- **[Codex セッション履歴の統一: 機能紹介と利用ガイド](../guides/codex-unified-session-history-guide-ja.md)**: 「統一 / 移行 / 復元」が実際に何を変えるのか、なぜデータが本当に失われないのか、そしてセッションが見えないときの自己点検と正確な復元の方法を解説します。**このトグルを使った、またはセッションが消えたと心配な方は、まずこちらをお読みください。**
|
||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 使用量ダッシュボードのデータソース(プロキシログ、セッション同期)と集計の仕組みを確認できます。本リリースで全体に効くプロバイダー / モデルフィルタを追加し、ルーティングテイクオーバー時の本物の課金モデルを表示するようにしました。
|
||||
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: カスタム User-Agent オーバーライド、Codex 統一セッション履歴などのトグルは、プロバイダーフォームの高度なオプションと設定ページにあります。
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
## 使用攻略
|
||||
|
||||
这一版用量统计的口径和看板做了较多调整,建议先看:
|
||||
本版新增了 **Codex 统一会话历史** 开关——它涉及会话的迁移 / 还原,操作不当时容易让人误以为"会话丢了",强烈建议先读这篇攻略;用量统计的口径和看板这一版也做了较多调整,一并附上:
|
||||
|
||||
- **[Codex 统一会话历史:功能介绍与使用攻略](../guides/codex-unified-session-history-guide-zh.md)**:讲清"统一 / 迁移 / 还原"到底改了什么、为什么数据不会真正丢失,以及看不到会话时如何自查与精确还原。**用过这个开关、或担心会话丢失,请务必先读。**
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源(代理日志、会话同步)与统计口径,本版新增了全局的供应商 / 模型筛选,并把路由接管的真实计价模型展示了出来。
|
||||
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:自定义 User-Agent 覆盖、Codex 统一会话历史等开关都在供应商表单的高级选项与设置页里。
|
||||
|
||||
|
||||
@@ -122,9 +122,6 @@ npm install -g @google/gemini-cli
|
||||
### Option 1: Homebrew (Recommended)
|
||||
|
||||
```bash
|
||||
# Add tap
|
||||
brew tap farion1231/ccswitch
|
||||
|
||||
# Install
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ v3.13.0 provides **ready-to-use built-in templates** for the following categorie
|
||||
|
||||
| Category | Covered Providers | Template Type |
|
||||
| ------------------ | --------------------------------------------------------- | ------------------------------- |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | Plan quota (with usage progress) |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine | Plan quota (with usage progress) |
|
||||
| Third-party balance| DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | Official balance query |
|
||||
|
||||
> **Tip**: Beyond these built-in templates, for uncovered providers you can use the **custom script** approach (see below) to write your own query logic.
|
||||
|
||||
@@ -122,9 +122,6 @@ npm install -g @google/gemini-cli
|
||||
### 方法 1:Homebrew(推奨)
|
||||
|
||||
```bash
|
||||
# tap を追加
|
||||
brew tap farion1231/ccswitch
|
||||
|
||||
# インストール
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ v3.13.0 では以下のカテゴリに **すぐに使える内蔵テンプレー
|
||||
|
||||
| カテゴリ | 対象プロバイダー | テンプレートタイプ |
|
||||
| --------------- | --------------------------------------------------------- | ------------------------- |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | プランクォータ(使用進捗付き) |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine(火山方舟) | プランクォータ(使用進捗付き) |
|
||||
| 第三者残高 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 公式残高クエリ |
|
||||
|
||||
> **ヒント**:上記の内蔵テンプレート以外で対象外のプロバイダーには、**カスタムスクリプト** 方式(下記参照)で独自のクエリロジックを記述できます。
|
||||
|
||||
@@ -138,9 +138,6 @@ npm install -g @google/gemini-cli --registry=https://registry.npmmirror.com
|
||||
### 方式一:Homebrew(推荐)
|
||||
|
||||
```bash
|
||||
# 添加 tap
|
||||
brew tap farion1231/ccswitch
|
||||
|
||||
# 安装
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ v3.13.0 为以下类别提供了**开箱即用的内置模板**,启用后无
|
||||
|
||||
| 类别 | 覆盖供应商 | 模板类型 |
|
||||
| ---------- | --------------------------------------------------------- | ----------------------- |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | 套餐配额(带使用进度) |
|
||||
| Token Plan | Kimi / Zhipu GLM / MiniMax / 火山方舟 | 套餐配额(带使用进度) |
|
||||
| 第三方余额 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 官方余额查询 |
|
||||
|
||||
> 💡 除了以上内置模板外,对未被覆盖的供应商,你可以使用**自定义脚本**方式(见下文)编写自己的查询逻辑。
|
||||
|
||||
Generated
+192
-15
@@ -184,7 +184,7 @@ dependencies = [
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -215,7 +215,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -241,7 +241,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -421,6 +421,29 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.69.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn 2.0.117",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -810,6 +833,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfb"
|
||||
version = "0.7.3"
|
||||
@@ -867,6 +899,17 @@ dependencies = [
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -936,6 +979,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -1178,7 +1230,7 @@ version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"convert_case 0.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@@ -1385,6 +1437,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -1914,7 +1972,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -2222,6 +2280,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -2616,6 +2683,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -2753,6 +2829,18 @@ dependencies = [
|
||||
"selectors 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lazycell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2779,7 +2867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||
dependencies = [
|
||||
"gtk-sys",
|
||||
"libloading",
|
||||
"libloading 0.7.4",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -2799,6 +2887,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
@@ -2822,6 +2920,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2954,6 +3058,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
@@ -3071,6 +3181,16 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -3839,7 +3959,7 @@ dependencies = [
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4002,7 +4122,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
@@ -4022,7 +4142,7 @@ dependencies = [
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
@@ -4278,6 +4398,12 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "relative-path"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
version = "0.4.2"
|
||||
@@ -4447,6 +4573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
|
||||
dependencies = [
|
||||
"rquickjs-core",
|
||||
"rquickjs-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4455,15 +4582,34 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
|
||||
dependencies = [
|
||||
"relative-path",
|
||||
"rquickjs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rquickjs-macro"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"indexmap 2.13.0",
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rquickjs-core",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rquickjs-sys"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
]
|
||||
|
||||
@@ -4507,6 +4653,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -4522,6 +4674,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -4531,7 +4696,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4775,7 +4940,7 @@ dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"rustc-hash 2.1.1",
|
||||
"servo_arc 0.4.3",
|
||||
"smallvec",
|
||||
]
|
||||
@@ -5814,7 +5979,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6783,6 +6948,18 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"once_cell",
|
||||
"rustix 0.38.44",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -7527,7 +7704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
@@ -7544,7 +7721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7601,7 +7778,7 @@ dependencies = [
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
|
||||
@@ -91,6 +91,9 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||
|
||||
[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies]
|
||||
rquickjs = { version = "0.8", features = ["bindgen"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
|
||||
@@ -1339,8 +1339,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn set_proxy_port(db: &Database, port: u16) {
|
||||
let mut config = crate::proxy::types::ProxyConfig::default();
|
||||
config.listen_port = port;
|
||||
let config = crate::proxy::types::ProxyConfig {
|
||||
listen_port: port,
|
||||
..Default::default()
|
||||
};
|
||||
futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||
use crate::config::{atomic_write, get_claude_mcp_path};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 需要在 Windows 上用 cmd /c 包装的命令
|
||||
@@ -98,51 +98,9 @@ pub struct McpStatus {
|
||||
}
|
||||
|
||||
fn user_config_path() -> PathBuf {
|
||||
ensure_mcp_override_migrated();
|
||||
get_claude_mcp_path()
|
||||
}
|
||||
|
||||
fn ensure_mcp_override_migrated() {
|
||||
if crate::settings::get_claude_override_dir().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_path = get_claude_mcp_path();
|
||||
if new_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let legacy_path = get_default_claude_mcp_path();
|
||||
if !legacy_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(parent) = new_path.parent() {
|
||||
if let Err(err) = fs::create_dir_all(parent) {
|
||||
log::warn!("创建 MCP 目录失败: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match fs::copy(&legacy_path, &new_path) {
|
||||
Ok(_) => {
|
||||
log::info!(
|
||||
"已根据覆盖目录复制 MCP 配置: {} -> {}",
|
||||
legacy_path.display(),
|
||||
new_path.display()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"复制 MCP 配置失败: {} -> {}: {}",
|
||||
legacy_path.display(),
|
||||
new_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
|
||||
@@ -67,6 +67,7 @@ const CC_SWITCH_LEGACY_CODEX_MODEL_PROVIDER_IDS: &[&str] = &[
|
||||
"dmxapi",
|
||||
"doubaoseed",
|
||||
"eflowcode",
|
||||
"etok",
|
||||
"kimi",
|
||||
"lemondata",
|
||||
"longcat",
|
||||
@@ -1116,16 +1117,23 @@ fn migrate_codex_state_dbs(
|
||||
}
|
||||
|
||||
fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec<PathBuf> {
|
||||
let mut paths = vec![codex_dir.join(CODEX_STATE_DB_FILENAME)];
|
||||
let mut paths = Vec::new();
|
||||
push_unique_path(&mut paths, codex_dir.join(CODEX_STATE_DB_FILENAME));
|
||||
// Codex lets SQLite state move away from CODEX_HOME; config takes precedence.
|
||||
if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) {
|
||||
let db_path = sqlite_home.join(CODEX_STATE_DB_FILENAME);
|
||||
if !paths.contains(&db_path) {
|
||||
paths.push(db_path);
|
||||
}
|
||||
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
|
||||
} else if let Some(sqlite_home) = sqlite_home_from_env() {
|
||||
push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let raw = doc.get("sqlite_home")?.as_str()?.trim();
|
||||
@@ -1135,6 +1143,15 @@ fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
|
||||
Some(resolve_user_path(raw))
|
||||
}
|
||||
|
||||
fn sqlite_home_from_env() -> Option<PathBuf> {
|
||||
let raw = std::env::var("CODEX_SQLITE_HOME").ok()?;
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(resolve_user_path(raw))
|
||||
}
|
||||
|
||||
fn resolve_user_path(raw: &str) -> PathBuf {
|
||||
if raw == "~" {
|
||||
return crate::config::get_home_dir();
|
||||
@@ -1313,8 +1330,33 @@ fn relative_backup_path(path: &Path, root: &Path) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::Provider;
|
||||
use serial_test::serial;
|
||||
use std::ffi::OsString;
|
||||
use tempfile::tempdir;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &Path) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = &self.previous {
|
||||
std::env::set_var(self.key, previous);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source_ids(values: &[&str]) -> BTreeSet<String> {
|
||||
values.iter().map(|value| value.to_string()).collect()
|
||||
}
|
||||
@@ -2116,6 +2158,46 @@ base_url = "https://proxy.example/v1"
|
||||
assert_eq!(backed_up_source_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn state_db_paths_include_codex_sqlite_home_env() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let sqlite_home = dir.path().join("sqlite-home");
|
||||
let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &sqlite_home);
|
||||
|
||||
let paths = codex_state_db_paths(&codex_dir, "");
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
codex_dir.join(CODEX_STATE_DB_FILENAME),
|
||||
sqlite_home.join(CODEX_STATE_DB_FILENAME),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn config_sqlite_home_takes_precedence_over_codex_sqlite_home_env() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let env_sqlite_home = dir.path().join("env-sqlite-home");
|
||||
let config_sqlite_home = dir.path().join("config-sqlite-home");
|
||||
let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home);
|
||||
let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display());
|
||||
|
||||
let paths = codex_state_db_paths(&codex_dir, &config_text);
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
codex_dir.join(CODEX_STATE_DB_FILENAME),
|
||||
config_sqlite_home.join(CODEX_STATE_DB_FILENAME),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_third_party_provider_ids_from_codex_providers() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
|
||||
@@ -4,6 +4,15 @@ use crate::services::subscription::SubscriptionQuota;
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
// 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。
|
||||
access_key_id: Option<String>,
|
||||
secret_access_key: Option<String>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
|
||||
crate::services::coding_plan::get_coding_plan_quota(
|
||||
&base_url,
|
||||
&api_key,
|
||||
access_key_id.as_deref(),
|
||||
secret_access_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
+292
-52
@@ -1069,6 +1069,111 @@ fn default_flag_for_shell(shell: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_user_shell() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"/bin/zsh"
|
||||
} else {
|
||||
"/bin/bash"
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_user_shell_path(shell: &str) -> bool {
|
||||
if shell.is_empty()
|
||||
|| !shell.starts_with('/')
|
||||
|| !is_valid_shell(shell)
|
||||
|| shell.chars().any(char::is_control)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = std::path::Path::new(shell);
|
||||
path.is_file() && is_executable_file(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_executable_file(path: &std::path::Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
path.metadata()
|
||||
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_executable_file(path: &std::path::Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。
|
||||
fn get_user_shell() -> String {
|
||||
std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|shell| valid_user_shell_path(shell))
|
||||
.unwrap_or_else(|| fallback_user_shell().to_string())
|
||||
}
|
||||
|
||||
/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。
|
||||
fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
|
||||
let quoted_shell = shell_single_quote(shell);
|
||||
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"zsh" => cwd
|
||||
.map(|dir| {
|
||||
let command = format!(
|
||||
"cd {} || exit 1; exec {} -i",
|
||||
shell_single_quote(&dir.to_string_lossy()),
|
||||
quoted_shell
|
||||
);
|
||||
format!("exec {} -lc {}", quoted_shell, shell_single_quote(&command))
|
||||
})
|
||||
.unwrap_or_else(|| format!("exec {quoted_shell} -l")),
|
||||
_ => format!("exec {quoted_shell}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。
|
||||
fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String {
|
||||
let claude_command = format!("claude --settings {}", shell_single_quote(config_path));
|
||||
let command = cwd
|
||||
.map(|dir| {
|
||||
format!(
|
||||
"cd {} && {}",
|
||||
shell_single_quote(&dir.to_string_lossy()),
|
||||
claude_command
|
||||
)
|
||||
})
|
||||
.unwrap_or(claude_command);
|
||||
|
||||
format!(
|
||||
"{} {} {}",
|
||||
shell_single_quote(shell),
|
||||
provider_command_flag_for_shell(shell),
|
||||
shell_single_quote(&command)
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_command_flag_for_shell(shell: &str) -> &'static str {
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"dash" | "sh" => "-c",
|
||||
"zsh" => "-lic",
|
||||
_ => "-ic",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String {
|
||||
if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
cwd.map(|dir| {
|
||||
format!(
|
||||
"cd {} || exit 1\n",
|
||||
shell_single_quote(&dir.to_string_lossy())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(
|
||||
tool: &str,
|
||||
@@ -2642,24 +2747,31 @@ fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||||
|
||||
let shell = get_user_shell();
|
||||
let exec_line = build_exec_line(&shell, cwd);
|
||||
let final_cd_command = build_final_shell_cd_command(&shell, cwd);
|
||||
|
||||
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 provider_command = build_provider_command_line(&shell, &config_path, cwd);
|
||||
|
||||
// Write the shell script to a temp file
|
||||
// 脚本使用 POSIX sh 语法确保可移植性,exec 行切换到用户交互式 shell
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/usr/bin/env sh
|
||||
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
|
||||
{provider_command}
|
||||
{final_cd_command}
|
||||
{exec_line}
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
provider_command = provider_command,
|
||||
final_cd_command = final_cd_command,
|
||||
exec_line = exec_line,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -2678,7 +2790,7 @@ exec bash --norc --noprofile
|
||||
"ghostty" => launch_macos_ghostty(&script_file),
|
||||
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
|
||||
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
|
||||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||
_ => launch_macos_terminal_app(&script_file),
|
||||
};
|
||||
|
||||
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
|
||||
@@ -2704,7 +2816,16 @@ fn applescript_string_literal(value: &str) -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn applescript_launcher_command(script_file: &std::path::Path) -> String {
|
||||
applescript_string_literal(&format!(
|
||||
"bash {}",
|
||||
"sh {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
))
|
||||
}
|
||||
|
||||
/// Build a launcher command that replaces the terminal-created shell session.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn applescript_exec_launcher_command(script_file: &std::path::Path) -> String {
|
||||
applescript_string_literal(&format!(
|
||||
"exec sh {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
))
|
||||
}
|
||||
@@ -2727,7 +2848,7 @@ tell application "Terminal"
|
||||
activate
|
||||
end if
|
||||
end tell"#,
|
||||
launcher = applescript_launcher_command(script_file)
|
||||
launcher = applescript_exec_launcher_command(script_file)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2795,7 +2916,7 @@ tell application "iTerm"
|
||||
write text launcher_script
|
||||
end tell
|
||||
end tell"#,
|
||||
launcher = applescript_launcher_command(script_file)
|
||||
launcher = applescript_exec_launcher_command(script_file)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2805,12 +2926,12 @@ fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||
run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2")
|
||||
}
|
||||
|
||||
/// Keep the launcher path inside a `bash -c` string.
|
||||
/// Keep the launcher path inside a `sh -c` string.
|
||||
/// A bare `.sh` passed through `open --args` may also be opened as a document.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_dash_c_command(script_file: &std::path::Path) -> String {
|
||||
format!(
|
||||
"exec bash {}",
|
||||
"exec sh {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
)
|
||||
}
|
||||
@@ -2865,8 +2986,8 @@ fn launch_macos_open_app(
|
||||
if use_e_flag {
|
||||
cmd.arg("-e");
|
||||
}
|
||||
// Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document.
|
||||
cmd.arg("bash")
|
||||
// Keep the script path inside `sh -c`; a trailing bare `.sh` can be opened as a document.
|
||||
cmd.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(build_macos_dash_c_command(script_file));
|
||||
|
||||
@@ -2912,9 +3033,9 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
|
||||
|
||||
rm -- "$0"
|
||||
|
||||
exec bash {}
|
||||
exec sh {quoted_script}
|
||||
"#,
|
||||
script_file.display(),
|
||||
quoted_script = shell_single_quote(&script_file.to_string_lossy()),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
|
||||
|
||||
@@ -2946,6 +3067,10 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
|
||||
let shell = get_user_shell();
|
||||
let exec_line = build_exec_line(&shell, cwd);
|
||||
let final_cd_command = build_final_shell_cd_command(&shell, cwd);
|
||||
|
||||
// Default terminal list with their arguments
|
||||
let default_terminals = [
|
||||
("gnome-terminal", vec!["--"]),
|
||||
@@ -2962,20 +3087,22 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R
|
||||
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 provider_command = build_provider_command_line(&shell, &config_path, cwd);
|
||||
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/usr/bin/env sh
|
||||
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
|
||||
{provider_command}
|
||||
{final_cd_command}
|
||||
{exec_line}
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display(),
|
||||
cd_command = cd_command,
|
||||
provider_command = provider_command,
|
||||
final_cd_command = final_cd_command,
|
||||
exec_line = exec_line,
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
@@ -3019,7 +3146,7 @@ exec bash --norc --noprofile
|
||||
if terminal_exists {
|
||||
let result = Command::new(terminal)
|
||||
.args(&args)
|
||||
.arg("bash")
|
||||
.arg("sh")
|
||||
.arg(script_file.to_string_lossy().as_ref())
|
||||
.spawn();
|
||||
|
||||
@@ -3106,16 +3233,6 @@ 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('\'', "'\"'\"'"))
|
||||
}
|
||||
@@ -3182,7 +3299,7 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -n 1` / `pause`
|
||||
/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -r` / `pause`
|
||||
/// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command
|
||||
/// not found` / `ModuleNotFoundError` 这类诊断信息。
|
||||
///
|
||||
@@ -3196,14 +3313,14 @@ pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result
|
||||
let (script_file, script_content) = {
|
||||
let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid));
|
||||
let content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/usr/bin/env sh
|
||||
trap 'rm -f "{script_path}"' EXIT
|
||||
echo "[cc-switch] Starting: {label}"
|
||||
echo ""
|
||||
{cmd}
|
||||
echo ""
|
||||
echo "[cc-switch] Command exited. Press any key to close."
|
||||
read -n 1 -s
|
||||
echo "[cc-switch] Command exited. Press Enter to close."
|
||||
read -r _
|
||||
"#,
|
||||
script_path = file.display(),
|
||||
label = label,
|
||||
@@ -3299,7 +3416,7 @@ read -n 1 -s
|
||||
if terminal_exists {
|
||||
let spawn_result = Command::new(terminal)
|
||||
.args(&args)
|
||||
.arg("bash")
|
||||
.arg("sh")
|
||||
.arg(script_file.to_string_lossy().as_ref())
|
||||
.spawn();
|
||||
match spawn_result {
|
||||
@@ -3387,6 +3504,126 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_test_executable(path: &Path, executable: bool) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = if executable { 0o755 } else { 0o644 };
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
||||
.expect("fixture permissions should be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_exec_line() {
|
||||
assert_eq!(build_exec_line("/bin/zsh", None), "exec '/bin/zsh' -l");
|
||||
assert_eq!(build_exec_line("/bin/bash", None), "exec '/bin/bash'");
|
||||
assert_eq!(
|
||||
build_exec_line("/opt/homebrew dir/bin/fish", None),
|
||||
"exec '/opt/homebrew dir/bin/fish'"
|
||||
);
|
||||
assert_eq!(build_exec_line("/bin/sh", None), "exec '/bin/sh'");
|
||||
assert_eq!(
|
||||
build_exec_line("/tmp/shell'quote/zsh", None),
|
||||
"exec '/tmp/shell'\"'\"'quote/zsh' -l"
|
||||
);
|
||||
assert_eq!(
|
||||
build_exec_line("/bin/zsh", Some(Path::new("/tmp/project"))),
|
||||
r#"exec '/bin/zsh' -lc 'cd '"'"'/tmp/project'"'"' || exit 1; exec '"'"'/bin/zsh'"'"' -i'"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_provider_command_line_uses_user_shell_environment() {
|
||||
assert_eq!(
|
||||
build_provider_command_line("/bin/zsh", "/tmp/claude config.json", None),
|
||||
"'/bin/zsh' -lic 'claude --settings '\"'\"'/tmp/claude config.json'\"'\"''"
|
||||
);
|
||||
assert_eq!(
|
||||
build_provider_command_line(
|
||||
"/bin/bash",
|
||||
"/tmp/claude config.json",
|
||||
Some(Path::new("/tmp/project"))
|
||||
),
|
||||
r#"'/bin/bash' -ic 'cd '"'"'/tmp/project'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"#
|
||||
);
|
||||
assert_eq!(
|
||||
build_provider_command_line(
|
||||
"/bin/sh",
|
||||
"/tmp/claude config.json",
|
||||
Some(Path::new("/tmp/project O'Brien"))
|
||||
),
|
||||
r#"'/bin/sh' -c 'cd '"'"'/tmp/project O'"'"'"'"'"'"'"'"'Brien'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_final_shell_cd_command() {
|
||||
assert_eq!(build_final_shell_cd_command("/bin/zsh", None), "");
|
||||
assert_eq!(
|
||||
build_final_shell_cd_command("/bin/zsh", Some(Path::new("/tmp/project"))),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
build_final_shell_cd_command("/bin/bash", Some(Path::new("/tmp/project O'Brien"))),
|
||||
"cd '/tmp/project O'\"'\"'Brien' || exit 1\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_get_user_shell_fallback() {
|
||||
// $SHELL 未设置时应按平台 fallback
|
||||
// 此测试验证 fallback 逻辑,但不验证环境变量值(取决于运行环境)
|
||||
let shell = get_user_shell();
|
||||
// 至少应返回一个合法的绝对路径
|
||||
assert!(valid_user_shell_path(&shell));
|
||||
// basename 应为合法 shell 名
|
||||
let basename = shell.rsplit('/').next().unwrap_or("sh");
|
||||
assert!(["sh", "bash", "zsh", "fish", "dash"].contains(&basename));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_valid_user_shell_path() {
|
||||
let temp = tempfile::tempdir().expect("temp dir should be created");
|
||||
let executable_zsh = temp.path().join("zsh");
|
||||
std::fs::write(&executable_zsh, "#!/usr/bin/env sh\n")
|
||||
.expect("shell fixture should be written");
|
||||
set_test_executable(&executable_zsh, true);
|
||||
|
||||
let executable_fish_dir = temp.path().join("homebrew dir/bin");
|
||||
std::fs::create_dir_all(&executable_fish_dir)
|
||||
.expect("shell fixture directory should be created");
|
||||
let executable_fish = executable_fish_dir.join("fish");
|
||||
std::fs::write(&executable_fish, "#!/usr/bin/env sh\n")
|
||||
.expect("shell fixture should be written");
|
||||
set_test_executable(&executable_fish, true);
|
||||
|
||||
let non_executable_bash = temp.path().join("bash");
|
||||
std::fs::write(&non_executable_bash, "#!/usr/bin/env sh\n")
|
||||
.expect("shell fixture should be written");
|
||||
set_test_executable(&non_executable_bash, false);
|
||||
|
||||
assert!(valid_user_shell_path(&executable_zsh.to_string_lossy()));
|
||||
assert!(valid_user_shell_path(&executable_fish.to_string_lossy()));
|
||||
assert!(!valid_user_shell_path(""));
|
||||
assert!(!valid_user_shell_path("zsh"));
|
||||
assert!(!valid_user_shell_path(
|
||||
&temp.path().join("missing/zsh").to_string_lossy()
|
||||
));
|
||||
assert!(!valid_user_shell_path(
|
||||
&non_executable_bash.to_string_lossy()
|
||||
));
|
||||
assert!(!valid_user_shell_path(
|
||||
&temp.path().join("zsh; rm -rf /").to_string_lossy()
|
||||
));
|
||||
assert!(!valid_user_shell_path(&format!(
|
||||
"{}\n/bin/bash",
|
||||
executable_zsh.to_string_lossy()
|
||||
)));
|
||||
assert!(!valid_user_shell_path("/usr/bin/powershell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_version() {
|
||||
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
|
||||
@@ -4850,13 +5087,6 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() {
|
||||
@@ -4918,6 +5148,10 @@ mod tests {
|
||||
),
|
||||
"already-running branch should use bare do script:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
script.contains(r#"set launcher_script to "exec sh '/tmp/cc_switch_launcher.sh'""#),
|
||||
"Terminal should replace the auto-created shell:\n{script}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Restored windows should not receive the launcher command.
|
||||
@@ -4943,7 +5177,7 @@ mod tests {
|
||||
|
||||
// Warm launches execute through the AppleScript command property, not `open -na ... -e`.
|
||||
assert!(
|
||||
script.contains(r#"set launcher_command to "bash '/tmp/cc_switch_launcher.sh'""#),
|
||||
script.contains(r#"set launcher_command to "sh '/tmp/cc_switch_launcher.sh'""#),
|
||||
"missing launcher_command:\n{script}"
|
||||
);
|
||||
assert!(script.contains("if was_running then"));
|
||||
@@ -4983,11 +5217,11 @@ mod tests {
|
||||
fn dash_c_command_wraps_script_path_inside_quoted_arg() {
|
||||
// The script path must stay inside the `-c` string, not as a bare argv.
|
||||
let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh"));
|
||||
assert_eq!(s, "exec bash '/tmp/cc_switch_launcher_1.sh'");
|
||||
assert_eq!(s, "exec sh '/tmp/cc_switch_launcher_1.sh'");
|
||||
|
||||
// Spaces and single quotes must stay shell-safe too.
|
||||
let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh"));
|
||||
assert_eq!(s2, r#"exec bash '/Users/me/it'"'"'s dir/x.sh'"#);
|
||||
assert_eq!(s2, r#"exec sh '/Users/me/it'"'"'s dir/x.sh'"#);
|
||||
}
|
||||
|
||||
/// AppleScript launchers need both shell-path quoting and AppleScript string quoting.
|
||||
@@ -4995,20 +5229,26 @@ mod tests {
|
||||
#[test]
|
||||
fn applescript_builders_safely_quote_special_paths() {
|
||||
// First shell-quote the path, then wrap the whole command as an AppleScript string.
|
||||
let expected = r#""bash '/Users/me/it'\"'\"'s dir/x.sh'""#;
|
||||
let expected = r#""sh '/Users/me/it'\"'\"'s dir/x.sh'""#;
|
||||
let p = Path::new("/Users/me/it's dir/x.sh");
|
||||
assert_eq!(applescript_launcher_command(p), expected);
|
||||
assert_eq!(
|
||||
applescript_exec_launcher_command(p),
|
||||
r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#
|
||||
);
|
||||
assert!(
|
||||
build_macos_terminal_applescript(p).contains(expected),
|
||||
build_macos_terminal_applescript(p)
|
||||
.contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#),
|
||||
"Terminal did not quote safely"
|
||||
);
|
||||
assert!(
|
||||
build_macos_iterm2_applescript(p).contains(expected),
|
||||
build_macos_iterm2_applescript(p)
|
||||
.contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#),
|
||||
"iTerm2 did not quote safely"
|
||||
);
|
||||
assert!(
|
||||
build_macos_ghostty_applescript(p).contains(expected),
|
||||
"Ghostty did not quote safely"
|
||||
"Ghostty did not keep the non-exec launcher"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -514,9 +514,19 @@ async fn query_provider_usage_inner(
|
||||
let (base_url, api_key) =
|
||||
resolve_coding_plan_credentials(&app_type, provider, usage_script);
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
// 火山方舟用账号 AK/SK 签名查询用量(存于 usage_script,与推理 api_key 分离);
|
||||
// 其他供应商为 None,service 层沿用 api_key。
|
||||
let access_key_id = usage_script.and_then(|s| s.access_key_id.clone());
|
||||
let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone());
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(
|
||||
&base_url,
|
||||
&api_key,
|
||||
access_key_id.as_deref(),
|
||||
secret_access_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
|
||||
// 将 SubscriptionQuota 转换为 UsageResult
|
||||
if !quota.success {
|
||||
@@ -1086,6 +1096,8 @@ mod native_query_credentials_tests {
|
||||
template_type: Some("token_plan".to_string()),
|
||||
auto_query_interval: None,
|
||||
coding_plan_provider: coding_plan_provider.map(str::to_string),
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
-26
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -47,25 +47,118 @@ pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||
get_home_dir().join(".claude.json")
|
||||
}
|
||||
|
||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||
let file_name = dir
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())?
|
||||
.trim()
|
||||
.to_string();
|
||||
if file_name.is_empty() {
|
||||
return None;
|
||||
fn normalize_path_lexically(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !normalized.pop() {
|
||||
normalized.push(component.as_os_str());
|
||||
}
|
||||
}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
||||
Some(parent.join(format!("{file_name}.json")))
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
||||
fn comparable_path_key(path: &Path) -> String {
|
||||
let mut key = normalize_path_lexically(path).to_string_lossy().to_string();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
key = key.replace('\\', "/");
|
||||
}
|
||||
|
||||
while key.len() > 1 && key.ends_with('/') {
|
||||
key.pop();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
key.make_ascii_lowercase();
|
||||
}
|
||||
|
||||
key
|
||||
}
|
||||
|
||||
fn path_eq_lexical(left: &Path, right: &Path) -> bool {
|
||||
comparable_path_key(left) == comparable_path_key(right)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn derive_wsl_default_mcp_path(dir: &Path) -> Option<PathBuf> {
|
||||
use std::path::Prefix;
|
||||
|
||||
let normalized = normalize_path_lexically(dir);
|
||||
let mut components = normalized.components();
|
||||
let prefix = match components.next()? {
|
||||
Component::Prefix(prefix) => prefix,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let server = match prefix.kind() {
|
||||
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => server.to_string_lossy(),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if !server.eq_ignore_ascii_case("wsl$") && !server.eq_ignore_ascii_case("wsl.localhost") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for component in components {
|
||||
match component {
|
||||
Component::RootDir | Component::CurDir => {}
|
||||
Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
|
||||
Component::ParentDir | Component::Prefix(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
let is_wsl_home_default =
|
||||
parts.len() == 3 && parts[0] == "home" && !parts[1].is_empty() && parts[2] == ".claude";
|
||||
let is_wsl_root_default = parts.len() == 2 && parts[0] == "root" && parts[1] == ".claude";
|
||||
|
||||
if is_wsl_home_default || is_wsl_root_default {
|
||||
return normalized
|
||||
.parent()
|
||||
.map(|parent| parent.join(".claude.json"));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn default_mcp_path_for_config_dir(dir: &Path) -> Option<PathBuf> {
|
||||
let default_config_dir = get_home_dir().join(".claude");
|
||||
if path_eq_lexical(dir, &default_config_dir) {
|
||||
return Some(get_default_claude_mcp_path());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(path) = derive_wsl_default_mcp_path(dir) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn derive_mcp_path_from_override(dir: &Path) -> PathBuf {
|
||||
dir.join(".claude.json")
|
||||
}
|
||||
|
||||
/// 获取 Claude MCP 配置文件路径
|
||||
pub fn get_claude_mcp_path() -> PathBuf {
|
||||
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
|
||||
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
|
||||
if let Some(path) = default_mcp_path_for_config_dir(&custom_dir) {
|
||||
return path;
|
||||
}
|
||||
return derive_mcp_path_from_override(&custom_dir);
|
||||
}
|
||||
get_default_claude_mcp_path()
|
||||
}
|
||||
@@ -263,33 +356,73 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_preserves_folder_name() {
|
||||
fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() {
|
||||
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
||||
let derived = derive_mcp_path_from_override(&override_dir)
|
||||
.expect("should derive path for nested dir");
|
||||
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
|
||||
fn derive_mcp_path_from_override_uses_config_dir_for_non_hidden_folder() {
|
||||
let override_dir = PathBuf::from("/data/claude-config");
|
||||
let derived = derive_mcp_path_from_override(&override_dir)
|
||||
.expect("should derive path for standard dir");
|
||||
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("/data/claude-config/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
|
||||
let override_dir = PathBuf::from("claude");
|
||||
let derived = derive_mcp_path_from_override(&override_dir)
|
||||
.expect("should derive path for single segment");
|
||||
assert_eq!(derived, PathBuf::from("claude.json"));
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("claude/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_root_like_dir_returns_none() {
|
||||
fn derive_mcp_path_from_root_like_dir_uses_root_file() {
|
||||
let override_dir = PathBuf::from("/");
|
||||
assert!(derive_mcp_path_from_override(&override_dir).is_none());
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, PathBuf::from("/.claude.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mcp_path_from_override_preserves_leading_parent_dirs() {
|
||||
let override_dir = PathBuf::from("../../profiles/work/.claude");
|
||||
let derived = derive_mcp_path_from_override(&override_dir);
|
||||
assert_eq!(derived, override_dir.join(".claude.json"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_unc_home_default_uses_split_mcp_path() {
|
||||
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude");
|
||||
let derived = default_mcp_path_for_config_dir(&override_dir)
|
||||
.expect("WSL home default should use split MCP path");
|
||||
assert_eq!(
|
||||
derived,
|
||||
PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_unc_root_default_uses_split_mcp_path() {
|
||||
let override_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude");
|
||||
let derived = default_mcp_path_for_config_dir(&override_dir)
|
||||
.expect("WSL root default should use split MCP path");
|
||||
assert_eq!(
|
||||
derived,
|
||||
PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_unc_custom_dir_uses_nested_mcp_path() {
|
||||
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude");
|
||||
assert!(default_mcp_path_for_config_dir(&override_dir).is_none());
|
||||
assert_eq!(
|
||||
derive_mcp_path_from_override(&override_dir),
|
||||
PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude\.claude.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -493,8 +493,10 @@ mod tests {
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let date_str = chrono::DateTime::from_timestamp(old_ts, 0)
|
||||
.unwrap()
|
||||
let date_str = Local
|
||||
.timestamp_opt(old_ts, 0)
|
||||
.single()
|
||||
.expect("old timestamp should be a valid local datetime")
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
conn.execute(
|
||||
|
||||
@@ -1853,6 +1853,7 @@ impl Database {
|
||||
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
||||
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
||||
("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"),
|
||||
// MiMo (小米)
|
||||
(
|
||||
"mimo-v2-flash",
|
||||
|
||||
@@ -235,6 +235,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
coding_plan_provider: None,
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
};
|
||||
|
||||
Ok(Some(ProviderMeta {
|
||||
|
||||
@@ -251,6 +251,14 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "codingPlanProvider")]
|
||||
pub coding_plan_provider: Option<String>,
|
||||
/// 火山方舟控制面 OpenAPI 的 AccessKey ID(用量查询签名用,与推理 Key 是两套凭据)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "accessKeyId")]
|
||||
pub access_key_id: Option<String>,
|
||||
/// 火山方舟控制面 OpenAPI 的 SecretAccessKey(同上)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "secretAccessKey")]
|
||||
pub secret_access_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
|
||||
@@ -1350,7 +1350,7 @@ impl RequestForwarder {
|
||||
.await;
|
||||
if restored > 0 {
|
||||
log::debug!(
|
||||
"[Codex] Restored {restored} cached function call(s) for Chat upstream"
|
||||
"[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream"
|
||||
);
|
||||
}
|
||||
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
|
||||
|
||||
@@ -145,6 +145,77 @@ pub fn normalize_anthropic_tool_thinking_history_for_provider(
|
||||
normalize_anthropic_tool_thinking_history(body)
|
||||
}
|
||||
|
||||
/// DeepSeek official Anthropic-compatible endpoint URL
|
||||
const DEEPSEEK_OFFICIAL_ANTHROPIC_URL: &str = "https://api.deepseek.com/anthropic";
|
||||
|
||||
/// Check whether the provider is configured to use DeepSeek's official
|
||||
/// Anthropic-compatible endpoint.
|
||||
fn is_deepseek_official_anthropic_endpoint(provider: &Provider) -> bool {
|
||||
let settings = &provider.settings_config;
|
||||
let base_url = settings
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| settings.get("base_url").and_then(|v| v.as_str()))
|
||||
.or_else(|| settings.get("baseURL").and_then(|v| v.as_str()))
|
||||
.or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str()));
|
||||
|
||||
base_url.map(|u| u.trim_end_matches('/')) == Some(DEEPSEEK_OFFICIAL_ANTHROPIC_URL)
|
||||
}
|
||||
|
||||
/// DeepSeek's official Anthropic-compatible endpoint treats
|
||||
/// `thinking: { type: "disabled" }` and effort parameters (`output_config.effort`
|
||||
/// or `reasoning_effort`) as mutually exclusive, returning HTTP 400:
|
||||
/// "thinking options type cannot be disabled when reasoning_effort is set".
|
||||
/// This breaks Claude Code 2.1.166+ Workflow/Dynamic Workflow features.
|
||||
///
|
||||
/// Rather than overriding Claude Code's intentional `thinking: disabled` for
|
||||
/// sub-agents, we respect that decision and remove the conflicting effort
|
||||
/// parameters instead. `thinking: disabled` means "don't output thinking
|
||||
/// blocks", which is the correct behavior for sub-agents that don't need
|
||||
/// to display reasoning to the user.
|
||||
///
|
||||
/// <https://github.com/deepseek-ai/DeepSeek-V3/issues/1397>
|
||||
pub fn normalize_deepseek_thinking_disabled_strip_effort(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
) -> bool {
|
||||
if !is_deepseek_official_anthropic_endpoint(provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let thinking_type = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
if thinking_type != Some("disabled") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// Remove output_config.effort (Anthropic format)
|
||||
if let Some(oc) = body
|
||||
.get_mut("output_config")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
changed |= oc.remove("effort").is_some();
|
||||
// Clean up empty output_config
|
||||
if oc.is_empty() {
|
||||
body.as_object_mut().unwrap().remove("output_config");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove reasoning_effort (OpenAI format, may be present in passthrough)
|
||||
if body.get("reasoning_effort").is_some() {
|
||||
body.as_object_mut().unwrap().remove("reasoning_effort");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn normalize_anthropic_messages_for_provider(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
@@ -154,77 +225,12 @@ pub fn normalize_anthropic_messages_for_provider(
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = normalize_anthropic_system_role_messages(body);
|
||||
changed |= normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||||
let mut changed =
|
||||
normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||||
changed |= normalize_deepseek_thinking_disabled_strip_effort(body, provider);
|
||||
changed
|
||||
}
|
||||
|
||||
fn normalize_anthropic_system_role_messages(body: &mut Value) -> bool {
|
||||
let mut system_parts = Vec::new();
|
||||
let changed = {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let original_len = messages.len();
|
||||
let mut kept_messages = Vec::with_capacity(messages.len());
|
||||
for message in std::mem::take(messages) {
|
||||
if message.get("role").and_then(Value::as_str) == Some("system") {
|
||||
if let Some(content) = message.get("content") {
|
||||
append_anthropic_system_parts(content, &mut system_parts);
|
||||
}
|
||||
} else {
|
||||
kept_messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = kept_messages.len() != original_len;
|
||||
*messages = kept_messages;
|
||||
changed
|
||||
};
|
||||
|
||||
if !changed || system_parts.is_empty() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
let mut merged_parts = Vec::new();
|
||||
if let Some(existing) = body.get("system") {
|
||||
append_anthropic_system_parts(existing, &mut merged_parts);
|
||||
}
|
||||
merged_parts.extend(system_parts);
|
||||
|
||||
if !merged_parts.is_empty() {
|
||||
body["system"] = Value::Array(merged_parts);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn append_anthropic_system_parts(content: &Value, parts: &mut Vec<Value>) {
|
||||
match content {
|
||||
Value::String(text) if !text.trim().is_empty() => {
|
||||
parts.push(json!({
|
||||
"type": "text",
|
||||
"text": text
|
||||
}));
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
append_anthropic_system_parts(item, parts);
|
||||
}
|
||||
}
|
||||
Value::Object(obj)
|
||||
if obj
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.trim().is_empty()) =>
|
||||
{
|
||||
parts.push(Value::Object(obj.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
@@ -2117,7 +2123,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_system_role_messages_move_to_top_level_system() {
|
||||
fn test_anthropic_messages_no_longer_hoists_system_role_messages() {
|
||||
// After reverting #3775, role=system messages are left in `messages[]`
|
||||
// (DeepSeek's endpoint accepts them natively) and the top-level `system`
|
||||
// field is untouched, preserving the request prefix.
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
@@ -2139,15 +2148,13 @@ mod tests {
|
||||
|
||||
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
|
||||
|
||||
assert!(changed);
|
||||
assert!(!changed);
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
|
||||
let system = body["system"].as_array().unwrap();
|
||||
assert_eq!(system[0]["text"], "Existing top-level system.");
|
||||
assert_eq!(system[1]["text"], "Message system one.");
|
||||
assert_eq!(system[2]["text"], "Message system two.");
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[0]["role"], "system");
|
||||
assert_eq!(messages[1]["role"], "user");
|
||||
assert_eq!(messages[2]["role"], "system");
|
||||
assert_eq!(body["system"], "Existing top-level system.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2300,4 +2307,245 @@ mod tests {
|
||||
assert!(!changed);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
// ==================== normalize_deepseek_thinking_disabled_strip_effort 测试 ====================
|
||||
|
||||
fn deepseek_official_provider() -> Provider {
|
||||
create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_strips_output_config_effort() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("output_config").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_strips_reasoning_effort() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"reasoning_effort": "high",
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_strips_both_effort_fields() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"reasoning_effort": "high",
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("output_config").is_none());
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_no_effort_no_change() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(!changed);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_preserves_output_config_other_fields() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max", "temperature": 0.5 },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["output_config"]["temperature"], 0.5);
|
||||
assert!(body["output_config"].get("effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_non_disabled_not_modified() {
|
||||
let cases = vec![
|
||||
(
|
||||
"enabled",
|
||||
json!({ "type": "enabled", "budget_tokens": 16000 }),
|
||||
),
|
||||
("adaptive", json!({ "type": "adaptive" })),
|
||||
];
|
||||
|
||||
for (label, thinking_value) in cases {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": thinking_value,
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
);
|
||||
|
||||
assert!(!changed, "should not modify thinking.type={label}");
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
// missing thinking field entirely
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
assert!(!normalize_deepseek_thinking_disabled_strip_effort(
|
||||
&mut body,
|
||||
&deepseek_official_provider()
|
||||
));
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_url_with_trailing_slash() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||
|
||||
assert!(changed);
|
||||
assert!(body.get("output_config").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_official_detected_via_base_url_fallback() {
|
||||
let provider = create_provider(json!({
|
||||
"base_url": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"reasoning_effort": "high",
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||
|
||||
assert!(changed);
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_deepseek_endpoint_not_modified() {
|
||||
let providers = vec![
|
||||
create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://other-api.com/anthropic", "ANTHROPIC_API_KEY": "test-key" }
|
||||
})),
|
||||
create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "test-key" }
|
||||
})),
|
||||
];
|
||||
|
||||
for provider in providers {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||
|
||||
assert!(
|
||||
!changed,
|
||||
"should not modify for {}",
|
||||
provider.settings_config["env"]["ANTHROPIC_BASE_URL"]
|
||||
);
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_messages_pipeline_strips_effort_for_deepseek() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"thinking": { "type": "disabled" },
|
||||
"output_config": { "effort": "max" },
|
||||
"max_tokens": 100000,
|
||||
"messages": [{ "role": "user", "content": "hello" }]
|
||||
});
|
||||
|
||||
let changed = normalize_anthropic_messages_for_provider(
|
||||
&mut body,
|
||||
&deepseek_official_provider(),
|
||||
"anthropic",
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
assert!(body.get("output_config").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ impl CodexChatHistoryStore {
|
||||
Some(item_type) if is_call_item_type(item_type) => {
|
||||
if let Some(call_id) = response_item_call_id(&item) {
|
||||
if let Some(cached) = lookup.call(&call_id) {
|
||||
if enrich_call_item_reasoning(&mut item, cached) {
|
||||
if enrich_call_item_from_cache(&mut item, cached) {
|
||||
enriched += 1;
|
||||
}
|
||||
}
|
||||
@@ -466,17 +466,26 @@ fn is_call_output_item_type(item_type: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn enrich_call_item_reasoning(item: &mut Value, cached: &Value) -> bool {
|
||||
fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool {
|
||||
let mut changed = false;
|
||||
for key in ["reasoning_content", "reasoning"] {
|
||||
for key in [
|
||||
"name",
|
||||
"namespace",
|
||||
"arguments",
|
||||
"input",
|
||||
"status",
|
||||
"execution",
|
||||
"reasoning_content",
|
||||
"reasoning",
|
||||
] {
|
||||
if item.get(key).is_some_and(|value| !is_empty_value(value)) {
|
||||
continue;
|
||||
}
|
||||
let Some(reasoning) = cached.get(key).filter(|value| !is_empty_value(value)) else {
|
||||
let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(object) = item.as_object_mut() {
|
||||
object.insert(key.to_string(), reasoning.clone());
|
||||
object.insert(key.to_string(), value.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -675,6 +684,48 @@ mod tests {
|
||||
assert_eq!(input.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enriches_existing_function_call_missing_name_and_arguments() {
|
||||
let history = CodexChatHistoryStore::default();
|
||||
history
|
||||
.record_response(&json!({
|
||||
"id": "resp_1",
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\":\"README.md\"}",
|
||||
"reasoning_content": "Need to inspect the file."
|
||||
}
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut request = json!({
|
||||
"previous_response_id": "resp_1",
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1"
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "ok"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(history.enrich_request(&mut request).await, 1);
|
||||
let input = request["input"].as_array().unwrap();
|
||||
assert_eq!(input[0]["type"], "function_call");
|
||||
assert_eq!(input[0]["name"], "read_file");
|
||||
assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}");
|
||||
assert_eq!(input[0]["reasoning_content"], "Need to inspect the file.");
|
||||
assert_eq!(input[1]["type"], "function_call_output");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_parallel_tool_calls_as_one_assistant_group() {
|
||||
let history = CodexChatHistoryStore::default();
|
||||
|
||||
@@ -429,8 +429,10 @@ impl ChatToResponsesState {
|
||||
if let Some(id) = id_delta {
|
||||
state.call_id = id;
|
||||
}
|
||||
if let Some(name) = name_delta {
|
||||
state.name = name;
|
||||
if let Some(ref name) = name_delta {
|
||||
if !name.is_empty() {
|
||||
state.name.clone_from(name);
|
||||
}
|
||||
}
|
||||
if !args_delta.is_empty() {
|
||||
state.arguments.push_str(&args_delta);
|
||||
@@ -442,7 +444,7 @@ impl ChatToResponsesState {
|
||||
}
|
||||
}
|
||||
|
||||
if !state.added && (!state.call_id.is_empty() || !state.name.is_empty()) {
|
||||
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
|
||||
should_add = true;
|
||||
pending_arguments = state.arguments.clone();
|
||||
} else if state.added {
|
||||
@@ -464,9 +466,6 @@ impl ChatToResponsesState {
|
||||
if state.call_id.is_empty() {
|
||||
state.call_id = format!("call_{chat_index}");
|
||||
}
|
||||
if state.name.is_empty() {
|
||||
state.name = "unknown_tool".to_string();
|
||||
}
|
||||
state.output_index = Some(assigned);
|
||||
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
|
||||
state.item_id = response_tool_call_item_id_from_chat_name(
|
||||
@@ -699,6 +698,21 @@ impl ChatToResponsesState {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip tool calls with missing names (defensive: some models generate
|
||||
// tool call deltas without providing a valid function name)
|
||||
let has_bad_name = self
|
||||
.tools
|
||||
.get(&key)
|
||||
.map(|state| state.name.is_empty())
|
||||
.unwrap_or(true);
|
||||
if has_bad_name {
|
||||
if let Some(state) = self.tools.get_mut(&key) {
|
||||
state.done = true;
|
||||
}
|
||||
log::warn!("[Codex] Skipping streaming tool call with missing name");
|
||||
continue;
|
||||
}
|
||||
|
||||
if self
|
||||
.tools
|
||||
.get(&key)
|
||||
@@ -713,9 +727,6 @@ impl ChatToResponsesState {
|
||||
if state.call_id.is_empty() {
|
||||
state.call_id = format!("call_{key}");
|
||||
}
|
||||
if state.name.is_empty() {
|
||||
state.name = "unknown_tool".to_string();
|
||||
}
|
||||
state.output_index = Some(assigned);
|
||||
state.item_id = response_tool_call_item_id_from_chat_name(
|
||||
&state.call_id,
|
||||
|
||||
@@ -1398,6 +1398,14 @@ fn chat_tool_calls_to_response_output_items(
|
||||
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for (index, tool_call) in tool_calls.iter().enumerate() {
|
||||
// Skip tool calls with missing function names (defensive: some models
|
||||
// may generate tool calls without providing a valid name)
|
||||
let function = tool_call.get("function").unwrap_or(&Value::Null);
|
||||
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name.is_empty() {
|
||||
log::warn!("[Codex] Skipping tool call with missing name");
|
||||
continue;
|
||||
}
|
||||
output.push(chat_tool_call_to_response_item(
|
||||
tool_call,
|
||||
index,
|
||||
@@ -1406,11 +1414,11 @@ fn chat_tool_calls_to_response_output_items(
|
||||
));
|
||||
}
|
||||
} else if let Some(function_call) = message.get("function_call") {
|
||||
output.push(chat_legacy_function_call_to_response_item(
|
||||
function_call,
|
||||
reasoning,
|
||||
tool_context,
|
||||
));
|
||||
if let Some(item) =
|
||||
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
|
||||
{
|
||||
output.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
@@ -1448,7 +1456,7 @@ fn chat_legacy_function_call_to_response_item(
|
||||
function_call: &Value,
|
||||
reasoning: Option<&str>,
|
||||
tool_context: &CodexToolContext,
|
||||
) -> Value {
|
||||
) -> Option<Value> {
|
||||
let call_id = function_call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1458,10 +1466,18 @@ fn chat_legacy_function_call_to_response_item(
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Skip legacy function calls with missing names (defensive: some models
|
||||
// may generate function_call without providing a valid name)
|
||||
if name.is_empty() {
|
||||
log::warn!("[Codex] Skipping legacy function_call with missing name");
|
||||
return None;
|
||||
}
|
||||
|
||||
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
|
||||
|
||||
let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
|
||||
response_tool_call_item_from_chat_name(
|
||||
Some(response_tool_call_item_from_chat_name(
|
||||
&item_id,
|
||||
"completed",
|
||||
call_id,
|
||||
@@ -1469,7 +1485,7 @@ fn chat_legacy_function_call_to_response_item(
|
||||
&arguments,
|
||||
reasoning,
|
||||
tool_context,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn response_tool_call_item_id_from_chat_name(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
|
||||
|
||||
use super::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_MONTHLY, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -17,6 +17,9 @@ enum CodingPlanProvider {
|
||||
MiniMaxCn,
|
||||
MiniMaxEn,
|
||||
ZenMux,
|
||||
/// 火山方舟 Agent Plan / Coding Plan(base_url 形如
|
||||
/// `https://ark.cn-beijing.volces.com/api/coding[/v3]`)。
|
||||
Volcengine,
|
||||
}
|
||||
|
||||
fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
@@ -33,6 +36,10 @@ fn detect_provider(base_url: &str) -> Option<CodingPlanProvider> {
|
||||
Some(CodingPlanProvider::MiniMaxEn)
|
||||
} else if url.contains("zenmux") {
|
||||
Some(CodingPlanProvider::ZenMux)
|
||||
} else if url.contains("volces.com/api/coding") {
|
||||
// 仅匹配 Coding/Agent Plan 入口;DouBaoSeed 按量付费走 /api/v3 与
|
||||
// /api/compatible,没有套餐额度,不在此命中。
|
||||
Some(CodingPlanProvider::Volcengine)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -59,6 +66,10 @@ fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
if let Some(n) = value.as_i64() {
|
||||
// 0/负时间戳(如火山 session 无活跃窗口回 -1)视为无重置时间
|
||||
if n <= 0 {
|
||||
return None;
|
||||
}
|
||||
// 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12
|
||||
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
|
||||
return millis_to_iso8601(ms);
|
||||
@@ -652,43 +663,517 @@ fn parse_minimax_tiers(body: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
tiers
|
||||
}
|
||||
|
||||
// ── 火山方舟 Agent Plan / Coding Plan ───────────────────────
|
||||
//
|
||||
// 与 Kimi/MiniMax(数据面 Bearer 余额接口)不同,火山用量接口是**控制面
|
||||
// OpenAPI**:统一网关 `open.volcengineapi.com`(**不是**数据面推理域名
|
||||
// `ark.cn-beijing.volces.com`),形如
|
||||
// `POST https://open.volcengineapi.com/?Action=...&Version=2024-01-01&Region=cn-beijing`,
|
||||
// **强制火山引擎签名 V4(AK/SK)**——实测复用推理 Bearer Key 会被网关以
|
||||
// `400 InvalidAuthorization` 拒绝(格式层拒绝,非权限问题)。因此用户需在用量查询
|
||||
// 里另填火山账号的 AccessKey ID + Secret(与推理 Key 是两套凭据)。两个 plan 用
|
||||
// 同一份 AK/SK,故鉴权类错误直接停、不再试另一个 plan。
|
||||
//
|
||||
// 自动探测:先调 `GetAFPUsage`(Agent Plan,回绝对额度 Quota/Used),未订阅再调
|
||||
// `GetCodingPlanUsage`(Coding Plan,回百分比)。
|
||||
|
||||
/// 控制面 OpenAPI 统一网关(区别于数据面推理域名 ark.cn-beijing.volces.com)。
|
||||
const VOLCENGINE_OPENAPI_HOST: &str = "open.volcengineapi.com";
|
||||
const VOLCENGINE_API_VERSION: &str = "2024-01-01";
|
||||
/// ark 控制面 OpenAPI 的默认 Region(Agent/Coding Plan 目前在 cn-beijing)。
|
||||
const VOLCENGINE_DEFAULT_REGION: &str = "cn-beijing";
|
||||
|
||||
/// 单次 OpenAPI 调用的归类结果。
|
||||
enum VolcCall {
|
||||
/// 2xx 且 JSON 可解析、无 OpenAPI 级错误(业务 Result 仍可能为空=未订阅)。
|
||||
Body(serde_json::Value),
|
||||
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
|
||||
/// 共用凭据,命中即停。
|
||||
Auth(String),
|
||||
/// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。
|
||||
Soft(String),
|
||||
}
|
||||
|
||||
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
|
||||
/// `ark.cn-beijing.volces.com` → `cn-beijing`);无法识别时回落 cn-beijing。
|
||||
/// 控制面 Host 是固定网关(`VOLCENGINE_OPENAPI_HOST`),不随 base_url 变化。
|
||||
fn volcengine_region(base_url: &str) -> String {
|
||||
let host = base_url
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(base_url)
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
host.split('.')
|
||||
.find(|p| p.starts_with("cn-") || p.starts_with("ap-"))
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string())
|
||||
}
|
||||
|
||||
/// 判断 OpenAPI 错误码是否属于鉴权类(需要硬停并提示换 AK/SK)。
|
||||
fn volcengine_is_auth_error_code(code: &str) -> bool {
|
||||
let c = code.to_lowercase();
|
||||
c.contains("auth")
|
||||
|| c.contains("signature")
|
||||
|| c.contains("accessdenied")
|
||||
|| c.contains("denied")
|
||||
|| c.contains("unauthorized")
|
||||
|| c.contains("forbidden")
|
||||
|| c.contains("credential")
|
||||
|| c.contains("token")
|
||||
}
|
||||
|
||||
/// 提取火山 OpenAPI 响应里的 `ResponseMetadata.Error`(或顶层 `Error`)。
|
||||
fn volcengine_response_error(body: &serde_json::Value) -> Option<(String, String)> {
|
||||
let err = body
|
||||
.get("ResponseMetadata")
|
||||
.and_then(|m| m.get("Error"))
|
||||
.or_else(|| body.get("Error"))?;
|
||||
let code = err
|
||||
.get("Code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let msg = err
|
||||
.get("Message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if code.is_empty() && msg.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((code, msg))
|
||||
}
|
||||
}
|
||||
|
||||
/// 鉴权失败时的引导文案,附加在错误后。
|
||||
const VOLCENGINE_AKSK_HINT: &str =
|
||||
"Check the AccessKey ID / Secret are correct and the account has Ark usage-query (OpenAPI) permission.";
|
||||
|
||||
// ── 火山引擎签名 V4(AK/SK)─────────────────────────────────
|
||||
//
|
||||
// 算法是 AWS SigV4 的火山变体(对照官方 volc-openapi-demos/signature/java/Sign.java)。
|
||||
// **两处致命差异,照搬 s3.rs 的标准 SigV4 会签名失败**:
|
||||
// 1. canonical headers 与 SignedHeaders 用**固定顺序**
|
||||
// `host;x-date;x-content-sha256;content-type`(**不按字母序**,s3.rs 是字母序);
|
||||
// 2. algorithm 串 `HMAC-SHA256`(无 `AWS4` 前缀)、credential scope 结尾 `request`
|
||||
// (非 `aws4_request`)、签名密钥 `kDate=HMAC(SK, date)`(SK 不加 `AWS4` 前缀)。
|
||||
// canonical query 仍按 key 字母序(与标准 SigV4 一致);service=`ark`、POST、空 body。
|
||||
|
||||
const VOLCENGINE_SERVICE: &str = "ark";
|
||||
const VOLCENGINE_CONTENT_TYPE: &str = "application/json; charset=utf-8";
|
||||
const VOLCENGINE_SIGNED_HEADERS: &str = "host;x-date;x-content-sha256;content-type";
|
||||
|
||||
fn volc_hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
use hmac::{Hmac, Mac};
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn volc_sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(data))
|
||||
}
|
||||
|
||||
/// RFC3986 unreserved 之外全部按 `%XX` 编码(用于 canonical query string)。
|
||||
fn volc_uri_encode(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for byte in input.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 构造按 key 字母序排序、逐段 URL 编码的 canonical query string。
|
||||
/// 同一份字符串既用于签名也用于实际请求 URL,保证两者完全一致。
|
||||
fn volcengine_canonical_query(action: &str, region: &str) -> String {
|
||||
let mut pairs = [
|
||||
("Action", action),
|
||||
("Region", region),
|
||||
("Version", VOLCENGINE_API_VERSION),
|
||||
];
|
||||
pairs.sort_by(|a, b| a.0.cmp(b.0));
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", volc_uri_encode(k), volc_uri_encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
/// 生成火山引擎签名 V4 的鉴权头,返回 `(Authorization, X-Date, X-Content-Sha256)`,
|
||||
/// 三者都要塞进请求头;`canonical_query` 必须与实际请求 URL 的 query 完全一致。
|
||||
/// `now` 作参数传入便于写确定性单测。
|
||||
fn volcengine_sign(
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
region: &str,
|
||||
canonical_query: &str,
|
||||
body: &[u8],
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> (String, String, String) {
|
||||
let x_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let short_date = now.format("%Y%m%d").to_string();
|
||||
let x_content_sha256 = volc_sha256_hex(body);
|
||||
|
||||
// 固定顺序 canonical headers(火山特有,**不排序**)。
|
||||
let canonical_headers = format!(
|
||||
"host:{VOLCENGINE_OPENAPI_HOST}\nx-date:{x_date}\nx-content-sha256:{x_content_sha256}\ncontent-type:{VOLCENGINE_CONTENT_TYPE}\n"
|
||||
);
|
||||
let canonical_request = format!(
|
||||
"POST\n/\n{canonical_query}\n{canonical_headers}\n{VOLCENGINE_SIGNED_HEADERS}\n{x_content_sha256}"
|
||||
);
|
||||
|
||||
let credential_scope = format!("{short_date}/{region}/{VOLCENGINE_SERVICE}/request");
|
||||
let string_to_sign = format!(
|
||||
"HMAC-SHA256\n{x_date}\n{credential_scope}\n{}",
|
||||
volc_sha256_hex(canonical_request.as_bytes())
|
||||
);
|
||||
|
||||
// 签名密钥派生:kDate=HMAC(SK, date)(SK **不加** AWS4 前缀),终止串 `request`。
|
||||
let k_date = volc_hmac_sha256(secret_access_key.as_bytes(), short_date.as_bytes());
|
||||
let k_region = volc_hmac_sha256(&k_date, region.as_bytes());
|
||||
let k_service = volc_hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes());
|
||||
let k_signing = volc_hmac_sha256(&k_service, b"request");
|
||||
let signature: String = volc_hmac_sha256(&k_signing, string_to_sign.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
|
||||
let authorization = format!(
|
||||
"HMAC-SHA256 Credential={access_key_id}/{credential_scope}, SignedHeaders={VOLCENGINE_SIGNED_HEADERS}, Signature={signature}"
|
||||
);
|
||||
(authorization, x_date, x_content_sha256)
|
||||
}
|
||||
|
||||
async fn volcengine_openapi_call(
|
||||
region: &str,
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
action: &str,
|
||||
) -> VolcCall {
|
||||
let client = crate::proxy::http_client::get();
|
||||
// canonical query 同时用于签名与实际 URL,确保两者逐字一致(否则签名不匹配)。
|
||||
let canonical_query = volcengine_canonical_query(action, region);
|
||||
let url = format!("https://{VOLCENGINE_OPENAPI_HOST}/?{canonical_query}");
|
||||
let body: &[u8] = b"";
|
||||
let (authorization, x_date, x_content_sha256) = volcengine_sign(
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
region,
|
||||
&canonical_query,
|
||||
body,
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Date", x_date)
|
||||
.header("X-Content-Sha256", x_content_sha256)
|
||||
.header("Content-Type", VOLCENGINE_CONTENT_TYPE)
|
||||
.header("Authorization", authorization)
|
||||
.body(body.to_vec())
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return VolcCall::Soft(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return VolcCall::Auth(format!(
|
||||
"Authentication failed (HTTP {status}). {VOLCENGINE_AKSK_HINT}"
|
||||
));
|
||||
}
|
||||
if !status.is_success() {
|
||||
// 火山 OpenAPI 网关对签名/凭据类错误常返 4xx(多为 HTTP 400)并携带与 200
|
||||
// 路径相同的 ResponseMetadata.Error 信封,而非 401/403。这里也解析信封,让
|
||||
// Bearer 被拒时仍能给出 AK/SK 引导并标记凭据失效,而不是当成普通 API 错误。
|
||||
let raw = resp.text().await.unwrap_or_default();
|
||||
if let Ok(body) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
if let Some((code, msg)) = volcengine_response_error(&body) {
|
||||
if volcengine_is_auth_error_code(&code) {
|
||||
return VolcCall::Auth(format!(
|
||||
"Authentication failed (HTTP {status}, {code}): {msg}. {VOLCENGINE_AKSK_HINT}"
|
||||
));
|
||||
}
|
||||
return VolcCall::Soft(format!("API error (HTTP {status}, {code}): {msg}"));
|
||||
}
|
||||
}
|
||||
return VolcCall::Soft(format!("API error (HTTP {status}): {raw}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
|
||||
// 火山 OpenAPI 业务错误常以 200 + ResponseMetadata.Error 返回。
|
||||
if let Some((code, msg)) = volcengine_response_error(&body) {
|
||||
if volcengine_is_auth_error_code(&code) {
|
||||
return VolcCall::Auth(format!(
|
||||
"Authentication failed ({code}): {msg}. {VOLCENGINE_AKSK_HINT}"
|
||||
));
|
||||
}
|
||||
return VolcCall::Soft(format!("API error ({code}): {msg}"));
|
||||
}
|
||||
|
||||
VolcCall::Body(body)
|
||||
}
|
||||
|
||||
/// 解析 `GetAFPUsage` 的 `Result` 为 tier 列表。
|
||||
///
|
||||
/// 展示 5h / 周 / 月三个窗口(与控制台一致);`AFPDaily` 被官方控制台隐藏
|
||||
/// (其 Quota 常高于周上限,属历史默认值而非强制限额),故跳过。
|
||||
/// `Quota`/`Used` 是绝对 AFP 值,已用百分比 = Used/Quota×100;`Quota<=0` 视为
|
||||
/// 该窗口未订阅/未启用,跳过——也用于把"已鉴权但无 Agent Plan"识别为空结果,
|
||||
/// 从而回落到 Coding Plan 探测。
|
||||
fn parse_afp_tiers(result: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
let mut tiers = Vec::new();
|
||||
for (key, name) in [
|
||||
("AFPFiveHour", TIER_FIVE_HOUR),
|
||||
("AFPWeekly", TIER_WEEKLY_LIMIT),
|
||||
("AFPMonthly", TIER_MONTHLY),
|
||||
] {
|
||||
let Some(win) = result.get(key) else { continue };
|
||||
let quota = win.get("Quota").and_then(parse_f64).unwrap_or(0.0);
|
||||
if quota <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let used = win.get("Used").and_then(parse_f64).unwrap_or(0.0);
|
||||
// 已用百分比;不做范围裁剪,与 parse_zhipu_token_tiers/parse_minimax_tiers
|
||||
// 的约定一致(下游渲染层负责显示策略)。
|
||||
let utilization = used / quota * 100.0;
|
||||
let resets_at = win.get("ResetTime").and_then(extract_reset_time);
|
||||
tiers.push(QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
});
|
||||
}
|
||||
tiers
|
||||
}
|
||||
|
||||
/// 把 `GetCodingPlanUsage` 的 window 标签归一到 tier 名。
|
||||
fn volcengine_coding_window(label: &str) -> Option<&'static str> {
|
||||
match label.to_lowercase().as_str() {
|
||||
"session" | "5h" | "fivehour" | "five_hour" | "rolling_5h" => Some(TIER_FIVE_HOUR),
|
||||
"weekly" | "week" | "7d" => Some(TIER_WEEKLY_LIMIT),
|
||||
"monthly" | "month" => Some(TIER_MONTHLY),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `GetCodingPlanUsage` 的 `Result` 为 tier 列表(防御式)。
|
||||
///
|
||||
/// 该接口官方文档未给出逐字段规格,依据官方 ark-cli 描述:回 session/weekly/
|
||||
/// monthly 窗口、**只给百分比**(已用)、重置时间是秒级。这里宽松匹配
|
||||
/// `QuotaUsage`/`Usages`/`Details` 数组及多种字段名,命中即用、未命中跳过。
|
||||
fn parse_coding_plan_tiers(result: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
let mut tiers = Vec::new();
|
||||
let arr = result
|
||||
.get("QuotaUsage")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| result.get("Usages").and_then(|v| v.as_array()))
|
||||
.or_else(|| result.get("Details").and_then(|v| v.as_array()));
|
||||
let Some(arr) = arr else { return tiers };
|
||||
|
||||
for item in arr {
|
||||
// 真实字段是 `Level`(实测 2026-06-21:session/weekly/monthly);其余作防御式 fallback。
|
||||
let label = item
|
||||
.get("Level")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| item.get("Type").and_then(|v| v.as_str()))
|
||||
.or_else(|| item.get("Period").and_then(|v| v.as_str()))
|
||||
.or_else(|| item.get("Label").and_then(|v| v.as_str()))
|
||||
.or_else(|| item.get("Window").and_then(|v| v.as_str()))
|
||||
.unwrap_or("");
|
||||
let Some(name) = volcengine_coding_window(label) else {
|
||||
continue;
|
||||
};
|
||||
let utilization = item
|
||||
.get("Percent")
|
||||
.and_then(parse_f64)
|
||||
.or_else(|| item.get("UsedPercent").and_then(parse_f64))
|
||||
.or_else(|| item.get("UsagePercent").and_then(parse_f64))
|
||||
.unwrap_or(0.0);
|
||||
// 兼容秒/毫秒/字符串(extract_reset_time 内部已区分秒与毫秒)。
|
||||
let resets_at = item
|
||||
.get("ResetTime")
|
||||
.or_else(|| item.get("ResetTimestamp"))
|
||||
.and_then(extract_reset_time);
|
||||
tiers.push(QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization,
|
||||
resets_at,
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
});
|
||||
}
|
||||
tiers
|
||||
}
|
||||
|
||||
fn volcengine_success(tiers: Vec<QuotaTier>, plan: Option<String>) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: plan,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
fn volcengine_auth_error(detail: String) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(detail),
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_volcengine(
|
||||
base_url: &str,
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
) -> SubscriptionQuota {
|
||||
let region = volcengine_region(base_url);
|
||||
let mut soft_errors: Vec<String> = Vec::new();
|
||||
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
|
||||
// 与"字段名/包裹层猜错")。签名若不通会走 Auth/Soft 分支,到不了这里。
|
||||
let mut empty_responses: Vec<String> = Vec::new();
|
||||
let summarize = |action: &str, body: &serde_json::Value| -> String {
|
||||
let raw: String = body.to_string().chars().take(700).collect();
|
||||
format!("{action}={raw}")
|
||||
};
|
||||
|
||||
// 1) Agent Plan:GetAFPUsage
|
||||
match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await {
|
||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")),
|
||||
VolcCall::Body(body) => {
|
||||
let result = body.get("Result").unwrap_or(&body);
|
||||
let tiers = parse_afp_tiers(result);
|
||||
if !tiers.is_empty() {
|
||||
let plan = result
|
||||
.get("PlanType")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| format!("Agent Plan {s}"));
|
||||
return volcengine_success(tiers, plan);
|
||||
}
|
||||
empty_responses.push(summarize("GetAFPUsage", &body));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Coding Plan:GetCodingPlanUsage
|
||||
match volcengine_openapi_call(
|
||||
®ion,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
"GetCodingPlanUsage",
|
||||
)
|
||||
.await
|
||||
{
|
||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")),
|
||||
VolcCall::Body(body) => {
|
||||
let result = body.get("Result").unwrap_or(&body);
|
||||
let tiers = parse_coding_plan_tiers(result);
|
||||
if !tiers.is_empty() {
|
||||
return volcengine_success(tiers, Some("Coding Plan".to_string()));
|
||||
}
|
||||
empty_responses.push(summarize("GetCodingPlanUsage", &body));
|
||||
}
|
||||
}
|
||||
|
||||
if !soft_errors.is_empty() {
|
||||
make_error(soft_errors.join("; "))
|
||||
} else if !empty_responses.is_empty() {
|
||||
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
|
||||
// 便于核对真实字段名/包裹层,或确认确实未订阅。
|
||||
make_error(format!(
|
||||
"No active subscription found (signature OK). Raw: {}",
|
||||
empty_responses.join(" || ")
|
||||
))
|
||||
} else {
|
||||
make_error(
|
||||
"No active Agent Plan or Coding Plan subscription found for this credential"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
/// 构造"凭据缺失 / 域名未命中"的失败结果(NotFound 状态 + 明确错误文案)。
|
||||
fn coding_plan_not_found(error: &str) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
error: Some(error.to_string()),
|
||||
queried_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
access_key_id: Option<&str>,
|
||||
secret_access_key: Option<&str>,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
|
||||
error: Some("API key is empty".to_string()),
|
||||
queried_at: None,
|
||||
});
|
||||
}
|
||||
|
||||
let provider = match detect_provider(base_url) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::NotFound,
|
||||
credential_message: None,
|
||||
success: false,
|
||||
tiers: vec![],
|
||||
extra_usage: None,
|
||||
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
|
||||
error: Some("Unknown coding plan provider".to_string()),
|
||||
queried_at: None,
|
||||
});
|
||||
}
|
||||
// 域名未命中已知套餐供应商(如第三方中转站):给出明确错误而非静默失败
|
||||
None => return Ok(coding_plan_not_found("Unknown coding plan provider")),
|
||||
};
|
||||
|
||||
// 火山方舟走控制面 AK/SK 签名(区别于其他供应商的数据面 Bearer api_key),凭据
|
||||
// 校验与查询路径都不同,单独分支提前处理。
|
||||
if let CodingPlanProvider::Volcengine = provider {
|
||||
let ak = access_key_id.unwrap_or("").trim();
|
||||
let sk = secret_access_key.unwrap_or("").trim();
|
||||
if ak.is_empty() || sk.is_empty() {
|
||||
return Ok(coding_plan_not_found(
|
||||
"Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)",
|
||||
));
|
||||
}
|
||||
return Ok(query_volcengine(base_url, ak, sk).await);
|
||||
}
|
||||
|
||||
// 其余供应商:数据面 Bearer api_key。
|
||||
// 与 balance::get_balance 一致:给出明确错误,避免 footer 显示无信息的失败
|
||||
if api_key.trim().is_empty() {
|
||||
return Ok(coding_plan_not_found("API key is empty"));
|
||||
}
|
||||
|
||||
let quota = match provider {
|
||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
|
||||
@@ -697,6 +1182,10 @@ pub async fn get_coding_plan_quota(
|
||||
CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await,
|
||||
CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await,
|
||||
CodingPlanProvider::ZenMux => query_zenmux(base_url, api_key).await,
|
||||
// 火山已在上面的 AK/SK 分支提前返回,此处不可达。
|
||||
CodingPlanProvider::Volcengine => {
|
||||
unreachable!("volcengine handled via AK/SK branch above")
|
||||
}
|
||||
};
|
||||
|
||||
Ok(quota)
|
||||
@@ -705,7 +1194,9 @@ pub async fn get_coding_plan_quota(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
parse_minimax_tiers, parse_zhipu_token_tiers, zhipu_quota_base, TIER_FIVE_HOUR,
|
||||
parse_afp_tiers, parse_coding_plan_tiers, parse_minimax_tiers, parse_zhipu_token_tiers,
|
||||
volcengine_canonical_query, volcengine_is_auth_error_code, volcengine_region,
|
||||
volcengine_response_error, volcengine_sign, zhipu_quota_base, TIER_FIVE_HOUR, TIER_MONTHLY,
|
||||
TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
use serde_json::json;
|
||||
@@ -1135,4 +1626,184 @@ mod tests {
|
||||
"https://open.bigmodel.cn"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 火山方舟 Agent Plan / Coding Plan ──
|
||||
|
||||
#[test]
|
||||
fn volcengine_afp_three_windows_from_official_example() {
|
||||
// 官方文档 GetAFPUsage 返回示例(逐字):5h 25% / weekly 30% / monthly
|
||||
// 42.525%;AFPDaily 被控制台隐藏,应跳过。
|
||||
let result = json!({
|
||||
"PlanType": "Large",
|
||||
"AFPFiveHour": { "Quota": 50.0, "Used": 12.5, "SubscribeTime": 1778788800000_i64, "ResetTime": 1778806800000_i64 },
|
||||
"AFPDaily": { "Quota": 100.0, "Used": 22.5, "SubscribeTime": 1778716800000_i64, "ResetTime": 1778803200000_i64 },
|
||||
"AFPWeekly": { "Quota": 500.0, "Used": 150.0, "SubscribeTime": 1778457600000_i64, "ResetTime": 1779062400000_i64 },
|
||||
"AFPMonthly": { "Quota": 2000.0, "Used": 850.5, "SubscribeTime": 1777939200000_i64, "ResetTime": 1780531200000_i64 }
|
||||
});
|
||||
let tiers = parse_afp_tiers(&result);
|
||||
assert_eq!(tiers.len(), 3, "daily 应被跳过,只剩 5h/周/月");
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert!((tiers[0].utilization - 25.0).abs() < 1e-9);
|
||||
assert!(tiers[0].resets_at.is_some());
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert!((tiers[1].utilization - 30.0).abs() < 1e-9);
|
||||
assert_eq!(tiers[2].name, TIER_MONTHLY);
|
||||
assert!((tiers[2].utilization - 42.525).abs() < 1e-9);
|
||||
assert!(tiers[2].resets_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_afp_zero_quota_windows_treated_as_unbound() {
|
||||
// 已鉴权但无 Agent Plan:窗口 Quota=0 → 空结果,调用方据此回落 Coding Plan。
|
||||
let result = json!({
|
||||
"PlanType": "",
|
||||
"AFPFiveHour": { "Quota": 0.0, "Used": 0.0 },
|
||||
"AFPWeekly": { "Quota": 0.0, "Used": 0.0 },
|
||||
"AFPMonthly": { "Quota": 0.0, "Used": 0.0 }
|
||||
});
|
||||
assert!(parse_afp_tiers(&result).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_afp_partial_windows_only_subscribed_ones() {
|
||||
// 仅 5h 窗口有额度(缺周/月)→ 只产出一个 tier。
|
||||
let result = json!({
|
||||
"AFPFiveHour": { "Quota": 40.0, "Used": 10.0, "ResetTime": 1778806800000_i64 },
|
||||
"AFPWeekly": { "Quota": 0.0, "Used": 0.0 }
|
||||
});
|
||||
let tiers = parse_afp_tiers(&result);
|
||||
assert_eq!(tiers.len(), 1);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert!((tiers[0].utilization - 25.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_coding_plan_real_response_levels() {
|
||||
// 真实 GetCodingPlanUsage 响应(用户实测 2026-06-21):字段名是 `Level`(非 `Type`),
|
||||
// 仅百分比,秒级 ResetTimestamp;session 无活跃窗口回 -1 → 无重置时间。
|
||||
let result = json!({
|
||||
"Status": "Running",
|
||||
"UpdateTimestamp": 1782053286_i64,
|
||||
"QuotaUsage": [
|
||||
{ "Level": "session", "Percent": 0.0, "ResetTimestamp": -1_i64 },
|
||||
{ "Level": "weekly", "Percent": 1.672568, "ResetTimestamp": 1782057600_i64 },
|
||||
{ "Level": "monthly", "Percent": 0.836284, "ResetTimestamp": 1784303999_i64 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_coding_plan_tiers(&result);
|
||||
assert_eq!(tiers.len(), 3);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert!((tiers[0].utilization - 0.0).abs() < 1e-9);
|
||||
assert!(
|
||||
tiers[0].resets_at.is_none(),
|
||||
"session ResetTimestamp=-1 应无重置时间"
|
||||
);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert!((tiers[1].utilization - 1.672568).abs() < 1e-6);
|
||||
assert!(tiers[1].resets_at.is_some());
|
||||
assert_eq!(tiers[2].name, TIER_MONTHLY);
|
||||
assert!((tiers[2].utilization - 0.836284).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_coding_plan_unknown_window_skipped_and_missing_array_empty() {
|
||||
let result = json!({
|
||||
"QuotaUsage": [
|
||||
{ "Level": "daily", "Percent": 9.0 },
|
||||
{ "Level": "weekly", "Percent": 20.0 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_coding_plan_tiers(&result);
|
||||
assert_eq!(tiers.len(), 1, "未知 daily 窗口跳过");
|
||||
assert_eq!(tiers[0].name, TIER_WEEKLY_LIMIT);
|
||||
|
||||
assert!(parse_coding_plan_tiers(&json!({})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_region_derivation() {
|
||||
assert_eq!(
|
||||
volcengine_region("https://ark.cn-beijing.volces.com/api/coding"),
|
||||
"cn-beijing"
|
||||
);
|
||||
// 其他 region 的数据面域名按段提取。
|
||||
assert_eq!(
|
||||
volcengine_region("https://ark.cn-shanghai.volces.com/api/coding/v3"),
|
||||
"cn-shanghai"
|
||||
);
|
||||
// 无可识别 region 段时回落默认 cn-beijing。
|
||||
assert_eq!(
|
||||
volcengine_region("https://example.com/api/coding"),
|
||||
"cn-beijing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_canonical_query_is_sorted_and_encoded() {
|
||||
// 按 key 字母序:Action < Region < Version;值含 `-` 属 unreserved,不编码。
|
||||
assert_eq!(
|
||||
volcengine_canonical_query("GetAFPUsage", "cn-beijing"),
|
||||
"Action=GetAFPUsage&Region=cn-beijing&Version=2024-01-01"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_sign_structure_and_determinism() {
|
||||
// 没有服务端金标准向量时,锁定签名的结构契约 + 确定性(足以抓住 header 顺序、
|
||||
// scope 后缀、algorithm 前缀、空 body hash 等实现错误)。真实正确性靠用户实测。
|
||||
let now = chrono::DateTime::parse_from_rfc3339("2024-06-21T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
let region = "cn-beijing";
|
||||
let query = volcengine_canonical_query("GetAFPUsage", region);
|
||||
let (auth, x_date, x_content) =
|
||||
volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now);
|
||||
|
||||
// 空 body 的 SHA-256(固定值),证明走的是空 body。
|
||||
assert_eq!(
|
||||
x_content,
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
// X-Date 形如 20240621T000000Z。
|
||||
assert_eq!(x_date, "20240621T000000Z");
|
||||
// Authorization 结构:算法无 AWS4 前缀、scope 结尾 ark/request、固定 SignedHeaders。
|
||||
assert!(
|
||||
auth.starts_with("HMAC-SHA256 Credential=AKLTtest/20240621/cn-beijing/ark/request,"),
|
||||
"unexpected credential/scope: {auth}"
|
||||
);
|
||||
assert!(
|
||||
auth.contains("SignedHeaders=host;x-date;x-content-sha256;content-type,"),
|
||||
"unexpected signed headers: {auth}"
|
||||
);
|
||||
// Signature 是 64 位十六进制。
|
||||
let sig = auth.rsplit("Signature=").next().unwrap();
|
||||
assert_eq!(sig.len(), 64);
|
||||
assert!(sig.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
|
||||
// 确定性:同输入同输出。
|
||||
let (auth2, _, _) = volcengine_sign("AKLTtest", "secretkey", region, &query, b"", now);
|
||||
assert_eq!(auth, auth2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_auth_error_code_detection_and_extraction() {
|
||||
assert!(volcengine_is_auth_error_code("AccessDenied"));
|
||||
assert!(volcengine_is_auth_error_code("SignatureDoesNotMatch"));
|
||||
assert!(volcengine_is_auth_error_code("InvalidAuthorization"));
|
||||
assert!(volcengine_is_auth_error_code("Unauthorized"));
|
||||
assert!(!volcengine_is_auth_error_code("InvalidParameter.Action"));
|
||||
assert!(!volcengine_is_auth_error_code("InternalError"));
|
||||
|
||||
// ResponseMetadata.Error 抽取
|
||||
let body = json!({
|
||||
"ResponseMetadata": { "RequestId": "x", "Error": { "Code": "AccessDenied", "Message": "no permission" } }
|
||||
});
|
||||
let (code, msg) = volcengine_response_error(&body).expect("应抽到 Error");
|
||||
assert_eq!(code, "AccessDenied");
|
||||
assert_eq!(msg, "no permission");
|
||||
|
||||
// 无 Error 时返回 None
|
||||
let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} });
|
||||
assert!(volcengine_response_error(&ok_body).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +463,7 @@ base_url = "http://localhost:8080"
|
||||
|
||||
db.update_proxy_config(ProxyConfig {
|
||||
live_takeover_active: true,
|
||||
listen_port: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
@@ -491,7 +492,7 @@ base_url = "http://localhost:8080"
|
||||
)
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
state
|
||||
let proxy_info = state
|
||||
.proxy_service
|
||||
.start()
|
||||
.await
|
||||
@@ -544,7 +545,7 @@ base_url = "http://localhost:8080"
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("http://127.0.0.1:15721"),
|
||||
Some(format!("http://127.0.0.1:{}", proxy_info.port).as_str()),
|
||||
"proxy base URL should stay intact"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -5444,8 +5444,10 @@ command = "shared-command"
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
let mut proxy_config = ProxyConfig::default();
|
||||
proxy_config.listen_port = 0;
|
||||
let proxy_config = ProxyConfig {
|
||||
listen_port: 0,
|
||||
..Default::default()
|
||||
};
|
||||
db.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("set test proxy config");
|
||||
@@ -5582,8 +5584,10 @@ requires_openai_auth = true
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let state = crate::store::AppState::new(db.clone());
|
||||
|
||||
let mut proxy_config = ProxyConfig::default();
|
||||
proxy_config.listen_port = 0;
|
||||
let proxy_config = ProxyConfig {
|
||||
listen_port: 0,
|
||||
..Default::default()
|
||||
};
|
||||
db.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("set test proxy config");
|
||||
|
||||
@@ -307,6 +307,11 @@ pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet";
|
||||
/// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。
|
||||
pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
|
||||
/// 月窗口 tier 名。火山方舟 Agent Plan / Coding Plan 有 5h / 周 / 月 三个展示
|
||||
/// 窗口(Kimi / MiniMax 只有 5h + 周),月窗口共用此标识;前端 `TIER_I18N_KEYS`
|
||||
/// 映射到 `subscription.monthly`。
|
||||
pub const TIER_MONTHLY: &str = "monthly";
|
||||
|
||||
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
||||
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
||||
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
||||
|
||||
@@ -1732,23 +1732,20 @@ impl Database {
|
||||
OR cache_read_tokens > 0 OR cache_creation_tokens > 0)";
|
||||
|
||||
let mut logs = {
|
||||
match only_model_id {
|
||||
Some(model) => {
|
||||
let sql = format!(
|
||||
"{BASE_SQL} AND (model = ?1 OR request_model = ?1 OR pricing_model = ?1)"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([model], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare(BASE_SQL)?;
|
||||
let rows = stmt.query_map([], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
}
|
||||
let mut stmt = conn.prepare(BASE_SQL)?;
|
||||
let rows = stmt.query_map([], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
// 精准回填的行筛选必须与查价层共用 candidates 归一化:SQL 精确匹配会漏掉
|
||||
// 以原始别名落库的行(如 openrouter/anthropic/claude-sonnet-4.5:free),
|
||||
// 这些行查价时能归一化命中新定价,却在筛选层被挡掉,导致导入定价后
|
||||
// 历史成本要等下次全量回填才更新。误纳无害——查不到价的行会被跳过。
|
||||
if let Some(model_id) = only_model_id {
|
||||
let target = model_pricing_candidates(model_id);
|
||||
logs.retain(|log| log_pricing_scope_matches(log, &target));
|
||||
}
|
||||
|
||||
if logs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -1966,6 +1963,30 @@ pub(crate) fn find_model_pricing_row(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 精准回填的行筛选:log 的任一模型字段归一化后与目标模型的 candidates 相交,
|
||||
/// 或可按查价层的前缀规则命中目标,即视为相关。镜像 find_model_pricing_row 的
|
||||
/// 匹配语义,宁可误纳(后续查价会兜底)不可漏筛。
|
||||
fn log_pricing_scope_matches(log: &RequestLogDetail, target_candidates: &[String]) -> bool {
|
||||
[
|
||||
Some(log.model.as_str()),
|
||||
log.request_model.as_deref(),
|
||||
log.pricing_model.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|field| {
|
||||
model_pricing_candidates(field).iter().any(|candidate| {
|
||||
target_candidates.iter().any(|target| {
|
||||
target == candidate
|
||||
|| (should_try_pricing_prefix_match(candidate)
|
||||
&& target
|
||||
.strip_prefix(candidate.as_str())
|
||||
.is_some_and(|rest| rest.starts_with('-')))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_placeholder_pricing_model(model_id: &str) -> bool {
|
||||
let normalized = model_id.trim().to_ascii_lowercase();
|
||||
normalized.is_empty() || matches!(normalized.as_str(), "unknown" | "null" | "none")
|
||||
@@ -2652,6 +2673,61 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scoped_backfill_matches_raw_alias_rows() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
// 代理日志按上游原文落库:带路由前缀和 :free 后缀的别名形式。
|
||||
// 精准回填的筛选必须归一化后匹配,否则这类行要等全量回填才更新。
|
||||
insert_usage_log(
|
||||
&conn,
|
||||
"openrouter-alias-zero-cost",
|
||||
"claude",
|
||||
"provider-1",
|
||||
"openrouter/moonshot/kimi-k2-novel:free",
|
||||
"proxy",
|
||||
1000,
|
||||
1_000_000,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
"0",
|
||||
)?;
|
||||
}
|
||||
|
||||
// 定价缺失时不应回填
|
||||
assert_eq!(db.backfill_missing_usage_costs()?, 0);
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
// 按归一化 ID 精准回填,应命中以原始别名落库的行
|
||||
assert_eq!(
|
||||
db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?,
|
||||
1
|
||||
);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let total_cost: String = conn.query_row(
|
||||
"SELECT total_cost_usd
|
||||
FROM proxy_request_logs WHERE request_id = 'openrouter-alias-zero-cost'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(total_cost, "0.600000");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
+34
-1
@@ -19,6 +19,8 @@ const W_TIER_NAMES: &[&str] = &[
|
||||
crate::services::subscription::TIER_SEVEN_DAY_OPUS,
|
||||
crate::services::subscription::TIER_SEVEN_DAY_SONNET,
|
||||
];
|
||||
// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)。
|
||||
const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY];
|
||||
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
|
||||
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
|
||||
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
@@ -26,6 +28,7 @@ const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[
|
||||
("h", H_TIER_NAMES),
|
||||
("w", W_TIER_NAMES),
|
||||
("m", M_TIER_NAMES),
|
||||
("p", GEMINI_PRO_TIER_NAMES),
|
||||
("f", GEMINI_FLASH_TIER_NAMES),
|
||||
("l", GEMINI_FLASH_LITE_TIER_NAMES),
|
||||
@@ -878,7 +881,7 @@ mod tests {
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
|
||||
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
|
||||
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
|
||||
TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
|
||||
@@ -1094,6 +1097,36 @@ mod tests {
|
||||
assert!(s.contains("w50%"), "expected w50% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_volcengine_three_tiers_with_monthly() {
|
||||
// 火山方舟 Agent Plan 回 5h/周/月三档,托盘应包含 m(月)窗口,
|
||||
// 不再静默丢弃。
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_FIVE_HOUR), 25.0),
|
||||
usage_data(Some(TIER_WEEKLY_LIMIT), 30.0),
|
||||
usage_data(Some(TIER_MONTHLY), 42.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("h25%"), "expected h25% in {s}");
|
||||
assert!(s.contains("w30%"), "expected w30% in {s}");
|
||||
assert!(s.contains("m42%"), "expected m42% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_monthly_only_renders_label_not_raw_name() {
|
||||
// 仅月窗口激活时不应回落到原始 "monthly" 机器名,而是走 m 标签。
|
||||
let r = usage_result(true, vec![usage_data(Some(TIER_MONTHLY), 60.0)]);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("m60%"), "expected m60% in {s}");
|
||||
assert!(
|
||||
!s.contains("monthly"),
|
||||
"raw tier name should not leak into label: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_official_subscription_claude_uses_h_and_w_labels() {
|
||||
let r = usage_result(
|
||||
|
||||
@@ -4,8 +4,9 @@ use std::fs;
|
||||
use serde_json::json;
|
||||
|
||||
use cc_switch_lib::{
|
||||
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
|
||||
AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||||
get_claude_mcp_path, get_claude_mcp_status, get_claude_settings_path,
|
||||
import_default_config_test_hook, read_claude_mcp_config, update_settings, AppError,
|
||||
AppSettings, AppType, McpApps, McpServer, McpService, MultiAppConfig,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
@@ -673,6 +674,274 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_default_claude_dir_keeps_default_split_mcp_path() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let claude_dir = home.join(".claude");
|
||||
fs::create_dir_all(&claude_dir).expect("create explicit default claude dir");
|
||||
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(claude_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set explicit default claude config dir");
|
||||
|
||||
assert_eq!(
|
||||
get_claude_mcp_path(),
|
||||
home.join(".claude.json"),
|
||||
"explicit default Claude dir should keep Claude Code's split MCP path"
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
McpService::upsert_server(
|
||||
&state,
|
||||
McpServer {
|
||||
id: "claude-default".to_string(),
|
||||
name: "Claude Default".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("sync default Claude MCP");
|
||||
|
||||
assert!(
|
||||
home.join(".claude.json").exists(),
|
||||
"default split MCP file should be written at home/.claude.json"
|
||||
);
|
||||
assert!(
|
||||
!claude_dir.join(".claude.json").exists(),
|
||||
"explicit default dir should not use nested .claude/.claude.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_claude_dir_writes_mcp_inside_config_dir() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let custom_dir = home.join("profiles").join(".claude");
|
||||
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
|
||||
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set custom claude config dir");
|
||||
|
||||
let expected_mcp_path = custom_dir.join(".claude.json");
|
||||
assert_eq!(
|
||||
get_claude_mcp_path(),
|
||||
expected_mcp_path,
|
||||
"custom Claude dir should keep MCP state inside the config dir"
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
McpService::upsert_server(
|
||||
&state,
|
||||
McpServer {
|
||||
id: "claude-custom".to_string(),
|
||||
name: "Claude Custom".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("sync custom Claude MCP");
|
||||
|
||||
assert!(
|
||||
expected_mcp_path.exists(),
|
||||
"custom Claude MCP file should be written inside custom dir"
|
||||
);
|
||||
assert!(
|
||||
!home.join("profiles").join(".claude.json").exists(),
|
||||
"custom Claude dir should not write sibling .claude.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_claude_dir_sync_does_not_copy_default_profile() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let home_mcp_path = home.join(".claude.json");
|
||||
let default_profile = json!({
|
||||
"hasCompletedOnboarding": true,
|
||||
"projects": {
|
||||
"/home-project": {
|
||||
"hasTrustDialogAccepted": true
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"home-only": {
|
||||
"type": "stdio",
|
||||
"command": "home-command"
|
||||
}
|
||||
},
|
||||
"profileSentinel": "home-profile"
|
||||
});
|
||||
let default_profile_text =
|
||||
serde_json::to_string_pretty(&default_profile).expect("serialize default profile");
|
||||
fs::write(&home_mcp_path, &default_profile_text).expect("seed default Claude profile");
|
||||
|
||||
let custom_dir = home.join("profiles").join("work").join(".claude");
|
||||
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set custom claude config dir");
|
||||
|
||||
let expected_mcp_path = custom_dir.join(".claude.json");
|
||||
assert_eq!(
|
||||
get_claude_mcp_path(),
|
||||
expected_mcp_path,
|
||||
"custom Claude dir should use nested .claude.json"
|
||||
);
|
||||
assert!(
|
||||
!expected_mcp_path.exists(),
|
||||
"custom profile should start without a live MCP file"
|
||||
);
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
McpService::upsert_server(
|
||||
&state,
|
||||
McpServer {
|
||||
id: "custom-only".to_string(),
|
||||
name: "Custom Only".to_string(),
|
||||
server: json!({
|
||||
"type": "stdio",
|
||||
"command": "custom-command"
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
hermes: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("sync custom Claude MCP");
|
||||
|
||||
let text = fs::read_to_string(&expected_mcp_path).expect("read custom Claude MCP");
|
||||
let value: serde_json::Value = serde_json::from_str(&text).expect("parse custom Claude MCP");
|
||||
let servers = value
|
||||
.get("mcpServers")
|
||||
.and_then(|v| v.as_object())
|
||||
.expect("custom profile should contain mcpServers");
|
||||
assert!(
|
||||
servers.contains_key("custom-only"),
|
||||
"custom profile should contain DB-managed Claude server"
|
||||
);
|
||||
assert!(
|
||||
!servers.contains_key("home-only"),
|
||||
"custom profile should not inherit default profile MCP servers"
|
||||
);
|
||||
assert!(
|
||||
value.get("hasCompletedOnboarding").is_none(),
|
||||
"custom profile should not inherit onboarding state"
|
||||
);
|
||||
assert!(
|
||||
value.get("projects").is_none(),
|
||||
"custom profile should not inherit project trust state"
|
||||
);
|
||||
assert!(
|
||||
value.get("profileSentinel").is_none(),
|
||||
"custom profile should not inherit unrelated default profile fields"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&home_mcp_path).expect("reread default Claude profile"),
|
||||
default_profile_text,
|
||||
"default Claude profile should remain unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_claude_dir_read_only_mcp_queries_do_not_create_profile() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
let home_mcp_path = home.join(".claude.json");
|
||||
fs::write(
|
||||
&home_mcp_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"mcpServers": {
|
||||
"home-only": {
|
||||
"type": "stdio",
|
||||
"command": "home-command"
|
||||
}
|
||||
},
|
||||
"profileSentinel": "home-profile"
|
||||
}))
|
||||
.expect("serialize default profile"),
|
||||
)
|
||||
.expect("seed default Claude profile");
|
||||
|
||||
let custom_dir = home.join("profiles").join("work").join(".claude");
|
||||
fs::create_dir_all(&custom_dir).expect("create custom claude dir");
|
||||
update_settings(AppSettings {
|
||||
claude_config_dir: Some(custom_dir.to_string_lossy().to_string()),
|
||||
..AppSettings::default()
|
||||
})
|
||||
.expect("set custom claude config dir");
|
||||
|
||||
let expected_mcp_path = custom_dir.join(".claude.json");
|
||||
assert!(
|
||||
!expected_mcp_path.exists(),
|
||||
"custom profile should start without a live MCP file"
|
||||
);
|
||||
|
||||
let status =
|
||||
futures::executor::block_on(get_claude_mcp_status()).expect("get Claude MCP status");
|
||||
assert_eq!(
|
||||
status.user_config_path,
|
||||
expected_mcp_path.to_string_lossy(),
|
||||
"status should report the custom profile MCP path"
|
||||
);
|
||||
assert!(
|
||||
!status.user_config_exists,
|
||||
"status should report missing custom profile MCP file"
|
||||
);
|
||||
let text =
|
||||
futures::executor::block_on(read_claude_mcp_config()).expect("read Claude MCP config");
|
||||
assert_eq!(text, None, "missing custom profile should read as None");
|
||||
assert!(
|
||||
!expected_mcp_path.exists(),
|
||||
"read-only MCP queries should not copy or create the custom profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
@@ -597,6 +597,14 @@ wire_api = "responses"
|
||||
|
||||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||||
|
||||
let mut proxy_config = state.db.get_proxy_config().await.expect("get proxy config");
|
||||
proxy_config.listen_port = 0;
|
||||
state
|
||||
.db
|
||||
.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("use ephemeral proxy port");
|
||||
|
||||
ProviderService::switch(&state, AppType::Codex, "deepseek-provider")
|
||||
.expect("switch from official subscription to DeepSeek");
|
||||
|
||||
@@ -623,6 +631,12 @@ wire_api = "responses"
|
||||
.set_takeover_for_app("codex", true)
|
||||
.await
|
||||
.expect("enable Codex takeover");
|
||||
let proxy_status = state
|
||||
.proxy_service
|
||||
.get_status()
|
||||
.await
|
||||
.expect("read proxy status after takeover");
|
||||
let codex_proxy_base_url = format!("http://127.0.0.1:{}/v1", proxy_status.port);
|
||||
|
||||
let auth_after_takeover: serde_json::Value =
|
||||
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover");
|
||||
@@ -634,7 +648,7 @@ wire_api = "responses"
|
||||
let config_after_takeover =
|
||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
|
||||
assert!(
|
||||
config_after_takeover.contains("http://127.0.0.1:15721/v1"),
|
||||
config_after_takeover.contains(&codex_proxy_base_url),
|
||||
"enabling takeover should point Codex config.toml at the local proxy"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn reset_test_fs() {
|
||||
".gemini",
|
||||
".config",
|
||||
".openclaw",
|
||||
"profiles",
|
||||
] {
|
||||
let path = home.join(sub);
|
||||
if path.exists() {
|
||||
|
||||
+29
-22
@@ -15,7 +15,6 @@ import {
|
||||
Book,
|
||||
Brain,
|
||||
Wrench,
|
||||
RefreshCw,
|
||||
History,
|
||||
BarChart2,
|
||||
Download,
|
||||
@@ -71,7 +70,11 @@ import { FailoverToggle } from "@/components/proxy/FailoverToggle";
|
||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import {
|
||||
SkillsPage,
|
||||
getSkillsPageHeaderActions,
|
||||
type SkillsPageSource,
|
||||
} from "@/components/skills/SkillsPage";
|
||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
|
||||
@@ -169,6 +172,8 @@ function App() {
|
||||
const sharedFeatureApp: AppId =
|
||||
activeApp === "claude-desktop" ? "claude" : activeApp;
|
||||
const [currentView, setCurrentView] = useState<View>(getInitialView);
|
||||
const [skillsDiscoverySource, setSkillsDiscoverySource] =
|
||||
useState<SkillsPageSource>("repos");
|
||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
||||
@@ -857,6 +862,11 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSkillsDiscovery = () => {
|
||||
setSkillsDiscoverySource("repos");
|
||||
setCurrentView("skillsDiscovery");
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
const content = (() => {
|
||||
switch (currentView) {
|
||||
@@ -884,7 +894,7 @@ function App() {
|
||||
return (
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||
onOpenDiscovery={handleOpenSkillsDiscovery}
|
||||
currentApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
@@ -897,6 +907,7 @@ function App() {
|
||||
initialApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
onSourceChange={setSkillsDiscoverySource}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -1312,7 +1323,7 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
onClick={handleOpenSkillsDiscovery}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
@@ -1322,24 +1333,20 @@ function App() {
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
{getSkillsPageHeaderActions(skillsDiscoverySource).map(
|
||||
({ key, labelKey, Icon, execute }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => execute(skillsPageRef.current)}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{t(labelKey)}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
|
||||
@@ -33,6 +33,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
|
||||
gemini_flash_lite: "subscription.geminiFlashLite",
|
||||
// Token Plan(five_hour 已在上方官方映射中)
|
||||
weekly_limit: "subscription.sevenDay",
|
||||
// 火山方舟 Agent Plan / Coding Plan 的月窗口
|
||||
monthly: "subscription.monthly",
|
||||
// GitHub Copilot
|
||||
premium: "subscription.copilotPremium",
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
extractCodexExperimentalBearerToken,
|
||||
@@ -196,6 +197,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settingsData } = useSettingsQuery();
|
||||
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
|
||||
// 生成带国际化的预设模板
|
||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||
@@ -538,8 +540,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
// Coding Plan 模板使用专用 API
|
||||
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
|
||||
// ZenMux 使用用户在脚本配置中手动填入的 API Key 和 Base URL
|
||||
// ZenMux 手填 baseUrl/apiKey;火山是 native 供应商,baseUrl 走推理配置,
|
||||
// 另用账号 AK/SK 签名查询控制面用量。
|
||||
const isZenMux = script.codingPlanProvider === "zenmux";
|
||||
const isVolcengine = script.codingPlanProvider === "volcengine";
|
||||
const baseUrl = isZenMux
|
||||
? (script.baseUrl ?? "")
|
||||
: (providerCredentials.baseUrl ?? "");
|
||||
@@ -547,7 +551,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
? (script.apiKey ?? "")
|
||||
: (providerCredentials.apiKey ?? "");
|
||||
const { subscriptionApi } = await import("@/lib/api/subscription");
|
||||
const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey);
|
||||
const quota = await subscriptionApi.getCodingPlanQuota(
|
||||
baseUrl,
|
||||
apiKey,
|
||||
isVolcengine ? script.accessKeyId : undefined,
|
||||
isVolcengine ? script.secretAccessKey : undefined,
|
||||
);
|
||||
if (quota.success && quota.tiers.length > 0) {
|
||||
const summary = quota.tiers
|
||||
.map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`)
|
||||
@@ -726,8 +735,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
providerCredentials.baseUrl,
|
||||
);
|
||||
const provider = script.codingPlanProvider || autoDetected || "kimi";
|
||||
// ZenMux 允许手动填写 API Key 和 Base URL,不清除
|
||||
// ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;其余清除。
|
||||
const isZenMux = provider === "zenmux";
|
||||
const isVolcengine = provider === "volcengine";
|
||||
setScript({
|
||||
...script,
|
||||
code: "",
|
||||
@@ -735,6 +745,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
baseUrl: isZenMux ? script.baseUrl : undefined,
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
accessKeyId: isVolcengine ? script.accessKeyId : undefined,
|
||||
secretAccessKey: isVolcengine ? script.secretAccessKey : undefined,
|
||||
codingPlanProvider: provider,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
|
||||
@@ -1246,6 +1258,83 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 火山方舟:控制面用量查询需账号 AK/SK(与推理 Key 是两套凭据) */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
|
||||
script.codingPlanProvider === "volcengine" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t("usageScript.credentialsConfig")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{t("usageScript.volcengineAkSkHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-volcengine-ak">
|
||||
{t("usageScript.accessKeyId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-volcengine-ak"
|
||||
type="text"
|
||||
value={script.accessKeyId || ""}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
accessKeyId: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="AKLT..."
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-volcengine-sk">
|
||||
{t("usageScript.secretAccessKey")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="usage-volcengine-sk"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={script.secretAccessKey || ""}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
secretAccessKey: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="••••••••"
|
||||
autoComplete="off"
|
||||
className="border-white/10"
|
||||
/>
|
||||
{script.secretAccessKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={
|
||||
showApiKey
|
||||
? t("apiKeyInput.hide")
|
||||
: t("apiKeyInput.show")
|
||||
}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通用配置(始终显示) */}
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
|
||||
{/* 超时时间 */}
|
||||
@@ -1333,6 +1422,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
darkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DRAG_REGION_STYLE,
|
||||
} from "@/lib/platform";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FullScreenPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,6 +18,11 @@ interface FullScreenPanelProps {
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/**
|
||||
* 覆盖内容区滚动容器的内边距/间距类。默认 `px-6 py-6 space-y-6`。
|
||||
* 通过 `cn`(twMerge) 合并,传入如 `pt-3` 只覆盖顶部内边距,其余保持默认。
|
||||
*/
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
|
||||
@@ -33,6 +39,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
contentClassName,
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -136,7 +143,9 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto scroll-overlay">
|
||||
<div className="px-6 py-6 space-y-6 w-full">{children}</div>
|
||||
<div className={cn("px-6 py-6 space-y-6 w-full", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -280,6 +280,9 @@ export function AddProviderDialog({
|
||||
const footer =
|
||||
!showUniversalTab || activeTab === "app-specific" ? (
|
||||
<>
|
||||
<span className="mr-auto min-w-0 text-xs text-muted-foreground truncate">
|
||||
{t("provider.addFooterHint")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -322,6 +325,7 @@ export function AddProviderDialog({
|
||||
title={t("provider.addNewProvider")}
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={footer}
|
||||
contentClassName="pt-3"
|
||||
>
|
||||
{showUniversalTab ? (
|
||||
<Tabs
|
||||
|
||||
@@ -320,7 +320,7 @@ export function ProviderCard({
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -335,7 +335,7 @@ export function ProviderCard({
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||
<ProviderIcon
|
||||
icon={provider.icon}
|
||||
name={provider.name}
|
||||
@@ -344,7 +344,7 @@ export function ProviderCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2 min-h-7">
|
||||
<h3 className="text-base font-semibold leading-none">
|
||||
{provider.name}
|
||||
@@ -451,7 +451,7 @@ export function ProviderCard({
|
||||
type="button"
|
||||
onClick={handleOpenWebsite}
|
||||
className={cn(
|
||||
"inline-flex items-center text-sm max-w-[280px]",
|
||||
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
|
||||
isClickableUrl
|
||||
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
|
||||
: "text-muted-foreground cursor-default",
|
||||
@@ -459,7 +459,7 @@ export function ProviderCard({
|
||||
title={displayUrl}
|
||||
disabled={!isClickableUrl}
|
||||
>
|
||||
<span className="truncate">{displayUrl}</span>
|
||||
<span className="min-w-0 truncate">{displayUrl}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>;
|
||||
@@ -245,8 +246,13 @@ export function ProviderList({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if ((event.metaKey || event.ctrlKey) && key === "f") {
|
||||
// 正在输入框/可编辑区域中时不抢占 Ctrl+F(例如添加供应商表单里
|
||||
// ProviderPresetSelector 的搜索框),避免与其同名快捷键冲突。
|
||||
if (isTextEditableTarget(document.activeElement)) return;
|
||||
event.preventDefault();
|
||||
setIsSearchOpen(true);
|
||||
return;
|
||||
@@ -257,8 +263,8 @@ export function ProviderList({
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
globalThis.addEventListener("keydown", handleKeyDown);
|
||||
return () => globalThis.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
@@ -471,6 +470,19 @@ export function OmoFormFields({
|
||||
const firstIsUnavailable =
|
||||
Boolean(currentVariant) &&
|
||||
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
|
||||
const defaultVariantLabel = t("omo.defaultWrapped", {
|
||||
defaultValue: "(Default)",
|
||||
});
|
||||
const getVariantLabel = (variant: string, index: number) =>
|
||||
firstIsUnavailable && index === 0
|
||||
? t("omo.currentValueUnavailable", {
|
||||
value: variant,
|
||||
defaultValue: "{{value}} (current value, unavailable)",
|
||||
})
|
||||
: variant;
|
||||
const selectedVariantLabel = currentVariant
|
||||
? getVariantLabel(currentVariant, 0)
|
||||
: defaultVariantLabel;
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -479,25 +491,21 @@ export function OmoFormFields({
|
||||
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
|
||||
<SelectValue
|
||||
placeholder={t("omo.variantPlaceholder", {
|
||||
defaultValue: "variant",
|
||||
})}
|
||||
/>
|
||||
<SelectTrigger
|
||||
className="w-28 min-w-0 h-8 overflow-hidden text-xs shrink-0"
|
||||
title={selectedVariantLabel}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selectedVariantLabel}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={EMPTY_VARIANT_VALUE}>
|
||||
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
|
||||
{defaultVariantLabel}
|
||||
</SelectItem>
|
||||
{variantOptions.map((variant, index) => (
|
||||
<SelectItem key={`${variant}-${index}`} value={variant}>
|
||||
{firstIsUnavailable && index === 0
|
||||
? t("omo.currentValueUnavailable", {
|
||||
value: variant,
|
||||
defaultValue: "{{value}} (current value, unavailable)",
|
||||
})
|
||||
: variant}
|
||||
{getVariantLabel(variant, index)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ProviderMeta,
|
||||
@@ -264,6 +265,7 @@ function ProviderFormFull({
|
||||
const { data: settingsData } = useSettingsQuery();
|
||||
const showCommonConfigNotice =
|
||||
settingsData != null && settingsData.commonConfigConfirmed !== true;
|
||||
const isDarkMode = useDarkMode();
|
||||
|
||||
const handleCommonConfigConfirm = async () => {
|
||||
try {
|
||||
@@ -2233,6 +2235,7 @@ function ProviderFormFull({
|
||||
rows={14}
|
||||
showValidation={false}
|
||||
language="json"
|
||||
darkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
) : appId === "opencode" &&
|
||||
@@ -2257,6 +2260,7 @@ function ProviderFormFull({
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
darkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
{settingsConfigErrorField}
|
||||
@@ -2287,6 +2291,7 @@ function ProviderFormFull({
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
darkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
|
||||
@@ -4,7 +4,15 @@ import { FormLabel } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||
import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
|
||||
import {
|
||||
ArrowUpAZ,
|
||||
Search,
|
||||
Zap,
|
||||
Star,
|
||||
Heart,
|
||||
Layers,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||
@@ -80,7 +88,21 @@ export function sortPresetEntries(
|
||||
t: PresetTranslator,
|
||||
): PresetEntry[] {
|
||||
if (sortMode === PresetSortMode.Original) {
|
||||
return [...entries];
|
||||
// 置顶优先级:官方分类 > 尊享合作伙伴(Kimi)> 其余原顺序。
|
||||
// 用分区拼接而非排序,确保每组内部各自的相对顺序都不变;
|
||||
// 排他条件保证「既是官方又是 prime」的预设只归入官方组、不被重复。
|
||||
const official = entries.filter(
|
||||
(entry) => entry.preset.category === "official",
|
||||
);
|
||||
const prime = entries.filter(
|
||||
(entry) =>
|
||||
entry.preset.category !== "official" && entry.preset.primePartner,
|
||||
);
|
||||
const rest = entries.filter(
|
||||
(entry) =>
|
||||
entry.preset.category !== "official" && !entry.preset.primePartner,
|
||||
);
|
||||
return [...official, ...prime, ...rest];
|
||||
}
|
||||
|
||||
return [...entries].sort((a, b) =>
|
||||
@@ -123,7 +145,7 @@ export function ProviderPresetSelector({
|
||||
onUniversalPresetSelect,
|
||||
onManageUniversalProviders,
|
||||
category,
|
||||
}: ProviderPresetSelectorProps) {
|
||||
}: Readonly<ProviderPresetSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -131,6 +153,7 @@ export function ProviderPresetSelector({
|
||||
PresetSortMode.Original,
|
||||
);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为
|
||||
useEffect(() => {
|
||||
@@ -150,6 +173,25 @@ export function ProviderPresetSelector({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [searchOpen]);
|
||||
|
||||
// 键盘快捷键: Ctrl/Cmd+F 打开搜索并聚焦输入框。
|
||||
// 使用捕获阶段并阻止冒泡,避免背后 ProviderList 的同名快捷键被意外触发。
|
||||
// 首次打开靠 Input 的 autoFocus 聚焦;若搜索已打开(例如点击 preset 后焦点
|
||||
// 停在按钮上),setSearchOpen(true) 同值不会重渲染、autoFocus 不重触发,
|
||||
// 这里用 rAF 命令式地把焦点移回搜索框(不 select,避免吞掉随后输入的首字符)。
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSearchOpen(true);
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => globalThis.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, []);
|
||||
|
||||
const visiblePresetEntries = useMemo(
|
||||
() =>
|
||||
getVisiblePresetEntries(presetEntries, {
|
||||
@@ -258,12 +300,13 @@ export function ProviderPresetSelector({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div ref={searchContainerRef} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>{t("providerPreset.label")}</FormLabel>
|
||||
<div ref={searchContainerRef} className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{searchOpen && (
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -278,7 +321,7 @@ export function ProviderPresetSelector({
|
||||
aria-label={t("providerPreset.searchAriaLabel", {
|
||||
defaultValue: "Search provider presets",
|
||||
})}
|
||||
className="w-48 h-8"
|
||||
className="w-60 h-8"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
@@ -359,6 +402,7 @@ export function ProviderPresetSelector({
|
||||
{visiblePresetEntries.map((entry) => {
|
||||
const isSelected = selectedPresetId === entry.id;
|
||||
const isPartner = entry.preset.isPartner;
|
||||
const isPrimePartner = entry.preset.primePartner;
|
||||
const presetCategory = entry.preset.category ?? "others";
|
||||
return (
|
||||
<button
|
||||
@@ -376,10 +420,18 @@ export function ProviderPresetSelector({
|
||||
<span className="truncate">
|
||||
{getPresetDisplayName(entry.preset, t)}
|
||||
</span>
|
||||
{isPartner && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Star className="h-2.5 w-2.5 fill-current" />
|
||||
</span>
|
||||
{isPrimePartner ? (
|
||||
<Heart
|
||||
className="absolute -top-1 -right-1 h-5 w-5 fill-amber-500 text-amber-500 drop-shadow-sm"
|
||||
strokeWidth={0}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
isPartner && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Star className="h-2.5 w-2.5 fill-current" />
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -387,50 +439,47 @@ export function ProviderPresetSelector({
|
||||
</div>
|
||||
|
||||
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
|
||||
{universalProviderPresets.map((preset) => (
|
||||
<button
|
||||
key={`universal-${preset.providerType}`}
|
||||
type="button"
|
||||
onClick={() => onUniversalPresetSelect(preset)}
|
||||
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
|
||||
title={t("universalProvider.hint", {
|
||||
defaultValue:
|
||||
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
|
||||
{universalProviderPresets.map((preset) => (
|
||||
<button
|
||||
key={`universal-${preset.providerType}`}
|
||||
type="button"
|
||||
onClick={() => onUniversalPresetSelect(preset)}
|
||||
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
|
||||
title={t("universalProvider.hint", {
|
||||
defaultValue: "跨应用统一配置,自动同步到 Claude/Codex/Gemini",
|
||||
})}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={preset.icon}
|
||||
name={preset.name}
|
||||
size={14}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate">{preset.name}</span>
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Layers className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{onManageUniversalProviders && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageUniversalProviders}
|
||||
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
|
||||
title={t("universalProvider.manage", {
|
||||
defaultValue: "管理统一供应商",
|
||||
})}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("universalProvider.manage", {
|
||||
defaultValue: "管理",
|
||||
})}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={preset.icon}
|
||||
name={preset.name}
|
||||
size={14}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate">{preset.name}</span>
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Layers className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{onManageUniversalProviders && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageUniversalProviders}
|
||||
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
|
||||
title={t("universalProvider.manage", {
|
||||
defaultValue: "管理统一供应商",
|
||||
})}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("universalProvider.manage", {
|
||||
defaultValue: "管理",
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MessageSquare,
|
||||
Clock,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
X,
|
||||
CheckSquare,
|
||||
} from "lucide-react";
|
||||
@@ -897,6 +898,38 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedSession.sourcePath && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleCopy(
|
||||
selectedSession.sourcePath!,
|
||||
t("sessionManager.sourcePathCopied"),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileText className="size-3 shrink-0" />
|
||||
<span className="font-mono truncate max-w-[200px]">
|
||||
{getBaseName(selectedSession.sourcePath)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="max-w-xs"
|
||||
>
|
||||
<p className="font-mono text-xs break-all">
|
||||
{selectedSession.sourcePath}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("sessionManager.clickToCopyPath")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Loader2,
|
||||
@@ -102,6 +109,7 @@ export function SettingsPage({
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("general");
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
const tabScrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -116,6 +124,12 @@ export function SettingsPage({
|
||||
}
|
||||
}, [requiresRestart]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (tabScrollContainerRef.current) {
|
||||
tabScrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const closeAfterSave = useCallback(() => {
|
||||
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
|
||||
acknowledgeRestart();
|
||||
@@ -226,7 +240,10 @@ export function SettingsPage({
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||
<div
|
||||
ref={tabScrollContainerRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pr-2"
|
||||
>
|
||||
<TabsContent value="general" className="space-y-6 mt-0">
|
||||
{settings ? (
|
||||
<motion.div
|
||||
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RefreshCw, Search, Loader2 } from "lucide-react";
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Loader2,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
@@ -36,8 +42,11 @@ import type {
|
||||
} from "@/lib/api/skills";
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
export type SkillsPageSource = "repos" | "skillssh";
|
||||
|
||||
interface SkillsPageProps {
|
||||
initialApp?: AppId;
|
||||
onSourceChange?: (source: SkillsPageSource) => void;
|
||||
}
|
||||
|
||||
export interface SkillsPageHandle {
|
||||
@@ -45,7 +54,35 @@ export interface SkillsPageHandle {
|
||||
openRepoManager: () => void;
|
||||
}
|
||||
|
||||
type SearchSource = "repos" | "skillssh";
|
||||
type SkillsPageHeaderAction = {
|
||||
key: string;
|
||||
sources: readonly SkillsPageSource[];
|
||||
labelKey: string;
|
||||
Icon: LucideIcon;
|
||||
execute: (page: SkillsPageHandle | null) => void;
|
||||
};
|
||||
|
||||
const SKILLS_PAGE_HEADER_ACTIONS: readonly SkillsPageHeaderAction[] = [
|
||||
{
|
||||
key: "refresh-repos",
|
||||
sources: ["repos"],
|
||||
labelKey: "skills.refresh",
|
||||
Icon: RefreshCw,
|
||||
execute: (page) => page?.refresh(),
|
||||
},
|
||||
{
|
||||
key: "manage-repos",
|
||||
sources: ["repos", "skillssh"],
|
||||
labelKey: "skills.repoManager",
|
||||
Icon: Settings,
|
||||
execute: (page) => page?.openRepoManager(),
|
||||
},
|
||||
];
|
||||
|
||||
export const getSkillsPageHeaderActions = (source: SkillsPageSource) =>
|
||||
SKILLS_PAGE_HEADER_ACTIONS.filter((action) =>
|
||||
action.sources.includes(source),
|
||||
);
|
||||
|
||||
const SKILLSSH_PAGE_SIZE = 20;
|
||||
|
||||
@@ -54,7 +91,7 @@ const SKILLSSH_PAGE_SIZE = 20;
|
||||
* 用于浏览和安装来自仓库或 skills.sh 的 Skills
|
||||
*/
|
||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
({ initialApp = "claude" }, ref) => {
|
||||
({ initialApp = "claude", onSourceChange }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -64,7 +101,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
>("all");
|
||||
|
||||
// skills.sh 搜索状态
|
||||
const [searchSource, setSearchSource] = useState<SearchSource>("repos");
|
||||
const [searchSource, setSearchSource] = useState<SkillsPageSource>("repos");
|
||||
const [skillsShInput, setSkillsShInput] = useState("");
|
||||
const [skillsShQuery, setSkillsShQuery] = useState("");
|
||||
const [skillsShOffset, setSkillsShOffset] = useState(0);
|
||||
@@ -90,23 +127,25 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
data: skillsShResult,
|
||||
isLoading: loadingSkillsSh,
|
||||
isFetching: fetchingSkillsSh,
|
||||
isPlaceholderData: placeholderSkillsSh,
|
||||
} = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset);
|
||||
|
||||
// 当搜索结果返回时累积
|
||||
useEffect(() => {
|
||||
if (skillsShResult) {
|
||||
if (skillsShResult && !placeholderSkillsSh) {
|
||||
if (skillsShOffset === 0) {
|
||||
setAccumulatedResults(skillsShResult.skills);
|
||||
} else {
|
||||
setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]);
|
||||
}
|
||||
}
|
||||
}, [skillsShResult, skillsShOffset]);
|
||||
}, [skillsShResult, skillsShOffset, placeholderSkillsSh]);
|
||||
|
||||
// 手动提交搜索
|
||||
const handleSkillsShSearch = () => {
|
||||
const trimmed = skillsShInput.trim();
|
||||
if (trimmed.length < 2) return;
|
||||
if (trimmed === skillsShQuery && skillsShOffset === 0) return;
|
||||
setSkillsShOffset(0);
|
||||
setAccumulatedResults([]);
|
||||
setSkillsShQuery(trimmed);
|
||||
@@ -314,13 +353,19 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
// 是否有更多 skills.sh 结果
|
||||
const hasMoreSkillsSh =
|
||||
skillsShResult && accumulatedResults.length < skillsShResult.totalCount;
|
||||
const searchingSkillsSh =
|
||||
(loadingSkillsSh || fetchingSkillsSh) && accumulatedResults.length === 0;
|
||||
|
||||
// 无仓库时默认切换到 skills.sh
|
||||
// 无仓库配置时默认切换到 skills.sh;仓库发现结果为空时仍保留仓库视图,方便手动刷新重试。
|
||||
const effectiveSource =
|
||||
searchSource === "repos" && skills.length === 0 && !loading
|
||||
searchSource === "repos" && repos.length === 0 && !loading
|
||||
? "skillssh"
|
||||
: searchSource;
|
||||
|
||||
useEffect(() => {
|
||||
onSourceChange?.(effectiveSource);
|
||||
}, [effectiveSource, onSourceChange]);
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
|
||||
{/* 技能网格(可滚动详情区域) */}
|
||||
@@ -528,7 +573,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
) : (
|
||||
/* ===== skills.sh 模式 ===== */
|
||||
<>
|
||||
{loadingSkillsSh && accumulatedResults.length === 0 ? (
|
||||
{searchingSkillsSh ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">
|
||||
@@ -542,7 +587,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
{t("skills.skillssh.searchPlaceholder")}
|
||||
</p>
|
||||
</div>
|
||||
) : accumulatedResults.length === 0 && !loadingSkillsSh ? (
|
||||
) : accumulatedResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{t("skills.skillssh.noResults", {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
@@ -87,6 +87,11 @@ const SelectItem = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,6 +35,7 @@ export function UniversalProviderFormModal({
|
||||
editingProvider,
|
||||
initialPreset,
|
||||
}: UniversalProviderFormModalProps) {
|
||||
const isDarkMode = useDarkMode();
|
||||
const { t } = useTranslation();
|
||||
const isEditMode = !!editingProvider;
|
||||
|
||||
@@ -658,6 +660,7 @@ requires_openai_auth = true`;
|
||||
value={JSON.stringify(claudeConfigJson, null, 2)}
|
||||
onChange={() => {}}
|
||||
height={180}
|
||||
darkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -673,6 +676,7 @@ requires_openai_auth = true`;
|
||||
value={JSON.stringify(codexConfigJson, null, 2)}
|
||||
onChange={() => {}}
|
||||
height={280}
|
||||
darkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -688,6 +692,7 @@ requires_openai_auth = true`;
|
||||
value={JSON.stringify(geminiConfigJson, null, 2)}
|
||||
onChange={() => {}}
|
||||
height={140}
|
||||
darkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, Search } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUpdateModelPricing } from "@/lib/query/usage";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
|
||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
|
||||
const DEFAULT_VISIBLE_ROWS = 50;
|
||||
const MAX_VISIBLE_ROWS = 200;
|
||||
|
||||
interface ModelsDevCost {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
}
|
||||
|
||||
interface ModelsDevModel {
|
||||
id?: string;
|
||||
name?: string;
|
||||
release_date?: string;
|
||||
cost?: ModelsDevCost;
|
||||
}
|
||||
|
||||
interface ModelsDevProvider {
|
||||
id?: string;
|
||||
name?: string;
|
||||
models?: Record<string, ModelsDevModel>;
|
||||
}
|
||||
|
||||
type ModelsDevResponse = Record<string, ModelsDevProvider>;
|
||||
|
||||
interface ModelsDevEntry {
|
||||
/** providerId/modelId,同一模型可能出现在多个供应商下 */
|
||||
key: string;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
modelId: string;
|
||||
/** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */
|
||||
normalizedId: string;
|
||||
modelName: string;
|
||||
/** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */
|
||||
releaseDate: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端 clean_model_id_for_pricing(usage_stats.rs)保持一致:
|
||||
* 取最后一个 '/' 之后的段、去掉 ':' 后缀、'@' 换成 '-'、转小写、去掉 [1m] 标记。
|
||||
* 成本归因查询用的就是这种归一化形式,原样入库的 ID 永远匹配不上。
|
||||
*/
|
||||
export function normalizeModelIdForPricing(modelId: string): string {
|
||||
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
|
||||
const beforeColon = afterSlash.split(":")[0] ?? "";
|
||||
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
|
||||
if (normalized.endsWith("[1m]")) {
|
||||
normalized = normalized.slice(0, -"[1m]".length).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */
|
||||
export function formatPrice(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
// toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 0 处理
|
||||
if (value >= 1e12) return "0";
|
||||
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
||||
return trimmed || "0";
|
||||
}
|
||||
|
||||
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
|
||||
const entries: ModelsDevEntry[] = [];
|
||||
for (const [providerId, provider] of Object.entries(data)) {
|
||||
if (!provider || typeof provider !== "object") continue;
|
||||
const providerName = provider.name || providerId;
|
||||
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
||||
const cost = model?.cost;
|
||||
const input = typeof cost?.input === "number" ? cost.input : null;
|
||||
const output = typeof cost?.output === "number" ? cost.output : null;
|
||||
if (input === null && output === null) continue;
|
||||
const normalizedId = normalizeModelIdForPricing(modelId);
|
||||
if (!normalizedId) continue;
|
||||
entries.push({
|
||||
key: `${providerId}/${modelId}`,
|
||||
providerId,
|
||||
providerName,
|
||||
modelId,
|
||||
normalizedId,
|
||||
modelName: model?.name || modelId,
|
||||
releaseDate:
|
||||
typeof model?.release_date === "string" ? model.release_date : "",
|
||||
input: input ?? 0,
|
||||
output: output ?? 0,
|
||||
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
|
||||
cacheWrite:
|
||||
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 最新发布的排在前面
|
||||
entries.sort(
|
||||
(a, b) =>
|
||||
b.releaseDate.localeCompare(a.releaseDate) ||
|
||||
a.modelName.localeCompare(b.modelName),
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface ModelsDevPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 导入成功后调用(此时定价列表已刷新) */
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
export function ModelsDevPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
onImported,
|
||||
}: ModelsDevPickerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const updatePricing = useUpdateModelPricing();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [providerFilter, setProviderFilter] = useState("all");
|
||||
const [selected, setSelected] = useState<ModelsDevEntry | null>(null);
|
||||
|
||||
// 每次打开时重置选择与过滤条件
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch("");
|
||||
setProviderFilter("all");
|
||||
setSelected(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["models-dev-pricing"],
|
||||
queryFn: async (): Promise<ModelsDevResponse> => {
|
||||
const res = await fetch(MODELS_DEV_API_URL);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
|
||||
|
||||
const providers = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (!map.has(entry.providerId)) {
|
||||
map.set(entry.providerId, entry.providerName);
|
||||
}
|
||||
}
|
||||
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
}, [entries]);
|
||||
|
||||
const isFiltering = search.trim() !== "" || providerFilter !== "all";
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
(providerFilter === "all" || entry.providerId === providerFilter) &&
|
||||
(!query ||
|
||||
entry.modelId.toLowerCase().includes(query) ||
|
||||
entry.normalizedId.includes(query) ||
|
||||
entry.modelName.toLowerCase().includes(query) ||
|
||||
entry.providerName.toLowerCase().includes(query)),
|
||||
);
|
||||
}, [entries, search, providerFilter]);
|
||||
|
||||
// 默认只展示最新发布的一批,搜索/筛选时展示全量匹配(设上限防卡顿)
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
|
||||
[filtered, isFiltering],
|
||||
);
|
||||
|
||||
// 单选:点击未选中的行替换选择,点击已选中的行取消选择。
|
||||
// 限制单选是为了避免批量导入时每条都触发一次全量零成本回填扫描(见 update_model_pricing)。
|
||||
const toggleEntry = (entry: ModelsDevEntry) => {
|
||||
setSelected((prev) => (prev?.key === entry.key ? null : entry));
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selected) return;
|
||||
|
||||
try {
|
||||
await updatePricing.mutateAsync({
|
||||
modelId: selected.normalizedId,
|
||||
displayName: selected.modelName,
|
||||
inputCost: formatPrice(selected.input),
|
||||
outputCost: formatPrice(selected.output),
|
||||
cacheReadCost: formatPrice(selected.cacheRead),
|
||||
cacheCreationCost: formatPrice(selected.cacheWrite),
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t("usage.modelsDevImported", {
|
||||
name: selected.modelName,
|
||||
defaultValue: "已导入 {{name}} 的定价",
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
onImported();
|
||||
} catch (error) {
|
||||
toast.error(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const priceColumns = (entry: ModelsDevEntry) =>
|
||||
[
|
||||
{ label: t("usage.inputCost", "输入成本"), value: entry.input },
|
||||
{ label: t("usage.outputCost", "输出成本"), value: entry.output },
|
||||
{ label: t("usage.cacheReadCost", "缓存命中"), value: entry.cacheRead },
|
||||
{
|
||||
label: t("usage.cacheWriteCost", "缓存创建"),
|
||||
value: entry.cacheWrite,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !updatePricing.isPending) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
zIndex="top"
|
||||
className="max-w-3xl h-[80vh]"
|
||||
onEscapeKeyDown={(e) => {
|
||||
// 在搜索框里按 ESC 不应关闭弹窗丢掉已选模型(与 FullScreenPanel 的约定一致)
|
||||
if (isTextEditableTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("usage.modelsDevPickerTitle", "从 models.dev 导入定价")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"usage.modelsDevPickerDesc",
|
||||
"选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>
|
||||
{t("usage.modelsDevLoadError", "加载 models.dev 数据失败")}:{" "}
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("usage.modelsDevRetry", "重试")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={providerFilter}
|
||||
onValueChange={setProviderFilter}
|
||||
>
|
||||
<SelectTrigger className="w-44 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[120] max-h-[min(24rem,var(--radix-select-content-available-height))]">
|
||||
<SelectItem value="all">
|
||||
{t("usage.modelsDevAllProviders", "全部供应商")}
|
||||
</SelectItem>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t(
|
||||
"usage.modelsDevSearchPlaceholder",
|
||||
"搜索模型或供应商(全量搜索)...",
|
||||
)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border border-border/50">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{t("usage.modelsDevNoResults", "没有匹配的模型")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{visible.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
role="button"
|
||||
aria-pressed={selected?.key === entry.key}
|
||||
onClick={() => toggleEntry(entry)}
|
||||
className={`flex cursor-pointer items-center gap-3 px-3 py-2 ${
|
||||
selected?.key === entry.key
|
||||
? "bg-accent/50"
|
||||
: "hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-4 w-4 shrink-0 text-primary ${
|
||||
selected?.key === entry.key
|
||||
? "visible"
|
||||
: "invisible"
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{entry.modelName}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{entry.providerName}
|
||||
</span>
|
||||
{entry.releaseDate && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||
{entry.releaseDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-xs text-muted-foreground"
|
||||
title={entry.modelId}
|
||||
>
|
||||
{entry.normalizedId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-3 text-right">
|
||||
{priceColumns(entry).map((column) => (
|
||||
<div key={column.label} className="w-16">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{column.label}
|
||||
</div>
|
||||
<div className="font-mono text-xs">
|
||||
${formatPrice(column.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length > visible.length && (
|
||||
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
|
||||
{isFiltering
|
||||
? t("usage.modelsDevTruncated", {
|
||||
shown: visible.length,
|
||||
total: filtered.length,
|
||||
defaultValue:
|
||||
"仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
|
||||
})
|
||||
: t("usage.modelsDevDefaultHint", {
|
||||
shown: visible.length,
|
||||
total: filtered.length,
|
||||
defaultValue:
|
||||
"默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={updatePricing.isPending}
|
||||
>
|
||||
{t("common.cancel", "取消")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selected || updatePricing.isPending}
|
||||
>
|
||||
{updatePricing.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
{t("usage.modelsDevImporting", "导入中...")}
|
||||
</>
|
||||
) : (
|
||||
t("usage.modelsDevImportButton", "导入")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Save, Plus } from "lucide-react";
|
||||
import { Save, Plus, Globe } from "lucide-react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useUpdateModelPricing } from "@/lib/query/usage";
|
||||
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
|
||||
import { ModelsDevPickerDialog } from "./ModelsDevPickerDialog";
|
||||
|
||||
interface PricingEditModalProps {
|
||||
open: boolean;
|
||||
@@ -26,6 +27,7 @@ export function PricingEditModal({
|
||||
}: PricingEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const updatePricing = useUpdateModelPricing();
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
modelId: model.modelId,
|
||||
@@ -111,6 +113,27 @@ export function PricingEditModal({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isNew && (
|
||||
<div className="mb-6 flex items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"usage.modelsDevHint",
|
||||
"无需手动填写,可从 models.dev 选择模型定价",
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsPickerOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Globe className="mr-1.5 h-4 w-4" />
|
||||
{t("usage.importFromModelsDev", "从 models.dev 导入")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
@@ -220,6 +243,17 @@ export function PricingEditModal({
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{isNew && isPickerOpen && (
|
||||
<ModelsDevPickerDialog
|
||||
open={isPickerOpen}
|
||||
onClose={() => setIsPickerOpen(false)}
|
||||
onImported={() => {
|
||||
setIsPickerOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FullScreenPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,9 +108,18 @@ export function UsageDashboard() {
|
||||
return getUsageRangePresetLabel(range.preset, t);
|
||||
}
|
||||
|
||||
return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date(
|
||||
resolvedRange.endDate * 1000,
|
||||
).toLocaleString(locale)}`;
|
||||
const startStr = new Date(resolvedRange.startDate * 1000).toLocaleString(
|
||||
locale,
|
||||
);
|
||||
|
||||
if (range.liveEndTime) {
|
||||
return `${startStr} → ${t("usage.liveEndTimeNow", "现在")}`;
|
||||
}
|
||||
|
||||
const endStr = new Date(resolvedRange.endDate * 1000).toLocaleString(
|
||||
locale,
|
||||
);
|
||||
return `${startStr} - ${endStr}`;
|
||||
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
|
||||
|
||||
// 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
@@ -117,6 +118,9 @@ export function UsageDateRangePicker({
|
||||
);
|
||||
const [draftStart, setDraftStart] = useState(resolvedRange.startDate);
|
||||
const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate);
|
||||
const [draftLiveEnd, setDraftLiveEnd] = useState(
|
||||
selection.preset === "custom" ? (selection.liveEndTime ?? false) : false,
|
||||
);
|
||||
const [displayMonth, setDisplayMonth] = useState(
|
||||
() =>
|
||||
new Date(
|
||||
@@ -136,6 +140,9 @@ export function UsageDateRangePicker({
|
||||
const r = resolveUsageRange(selection);
|
||||
setDraftStart(r.startDate);
|
||||
setDraftEnd(r.endDate);
|
||||
setDraftLiveEnd(
|
||||
selection.preset === "custom" ? (selection.liveEndTime ?? false) : false,
|
||||
);
|
||||
setDisplayMonth(
|
||||
new Date(
|
||||
fromTs(r.startDate).getFullYear(),
|
||||
@@ -147,6 +154,15 @@ export function UsageDateRangePicker({
|
||||
setError(null);
|
||||
}, [open, selection]);
|
||||
|
||||
// Keep draftEnd ticking when live mode is active and popover is open
|
||||
useEffect(() => {
|
||||
if (!open || !draftLiveEnd) return;
|
||||
const tick = () => setDraftEnd(Math.floor(Date.now() / 1000));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [open, draftLiveEnd]);
|
||||
|
||||
const calendarDays = useMemo(
|
||||
() => getCalendarDays(displayMonth),
|
||||
[displayMonth],
|
||||
@@ -169,6 +185,14 @@ export function UsageDateRangePicker({
|
||||
/* Pick a date from the calendar */
|
||||
const handleDatePick = (day: Date) => {
|
||||
setError(null);
|
||||
|
||||
// When live end time is active, calendar only controls start date
|
||||
if (draftLiveEnd) {
|
||||
const nextTs = setDateKeepTime(draftStart, day);
|
||||
setDraftStart(nextTs);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTs = setDateKeepTime(
|
||||
activeField === "start" ? draftStart : draftEnd,
|
||||
day,
|
||||
@@ -211,6 +235,7 @@ export function UsageDateRangePicker({
|
||||
preset: "custom",
|
||||
customStartDate: draftStart,
|
||||
customEndDate: draftEnd,
|
||||
liveEndTime: draftLiveEnd,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
@@ -222,6 +247,7 @@ export function UsageDateRangePicker({
|
||||
/* ── Field card (start / end) ── */
|
||||
const renderField = (field: DraftField) => {
|
||||
const isActive = activeField === field;
|
||||
const isEndLive = field === "end" && draftLiveEnd;
|
||||
const ts = field === "start" ? draftStart : draftEnd;
|
||||
const setTs = field === "start" ? setDraftStart : setDraftEnd;
|
||||
const label =
|
||||
@@ -232,12 +258,16 @@ export function UsageDateRangePicker({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 cursor-pointer transition-all",
|
||||
isActive
|
||||
? "border-primary ring-1 ring-primary/30 bg-primary/5"
|
||||
: "border-border/50 hover:border-border",
|
||||
"rounded-lg border px-3 py-2 transition-all",
|
||||
isEndLive
|
||||
? "border-border/30 bg-muted/30 cursor-not-allowed opacity-50"
|
||||
: isActive
|
||||
? "border-primary ring-1 ring-primary/30 bg-primary/5 cursor-pointer"
|
||||
: "border-border/50 hover:border-border cursor-pointer",
|
||||
)}
|
||||
onClick={() => setActiveField(field)}
|
||||
onClick={() => {
|
||||
if (!isEndLive) setActiveField(field);
|
||||
}}
|
||||
>
|
||||
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
@@ -245,27 +275,41 @@ export function UsageDateRangePicker({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
|
||||
className={cn(
|
||||
"h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0",
|
||||
isEndLive && "pointer-events-none",
|
||||
)}
|
||||
value={fmtDate(ts)}
|
||||
onChange={(e) => {
|
||||
if (isEndLive) return;
|
||||
const next = parseDateInput(ts, e.target.value);
|
||||
setTs(next);
|
||||
const d = fromTs(next);
|
||||
setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={() => setActiveField(field)}
|
||||
onFocus={() => {
|
||||
if (!isEndLive) setActiveField(field);
|
||||
}}
|
||||
readOnly={isEndLive}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
step={60}
|
||||
className="h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
|
||||
className={cn(
|
||||
"h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0",
|
||||
isEndLive && "pointer-events-none",
|
||||
)}
|
||||
value={fmtTime(ts)}
|
||||
onChange={(e) => {
|
||||
if (isEndLive) return;
|
||||
setTs(parseTimeInput(ts, e.target.value));
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={() => setActiveField(field)}
|
||||
onFocus={() => {
|
||||
if (!isEndLive) setActiveField(field);
|
||||
}}
|
||||
readOnly={isEndLive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +331,7 @@ export function UsageDateRangePicker({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[340px] max-w-[calc(100vw-2rem)] p-3 sm:w-[620px]"
|
||||
className="usage-range-popover w-[620px] max-w-[calc(100vw-2rem)] p-3"
|
||||
align="end"
|
||||
>
|
||||
{/* Preset shortcuts */}
|
||||
@@ -309,15 +353,32 @@ export function UsageDateRangePicker({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="usage-range-layout flex flex-col gap-3">
|
||||
{/* Left: date fields */}
|
||||
<div className="space-y-2 sm:w-[250px] sm:flex-none">
|
||||
<div className="usage-range-fields space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usage.customRangeHint", "支持日期与时间,最长 30 天")}
|
||||
</p>
|
||||
{renderField("start")}
|
||||
{renderField("end")}
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={draftLiveEnd}
|
||||
onCheckedChange={(checked) => {
|
||||
const live = checked === true;
|
||||
setDraftLiveEnd(live);
|
||||
if (live) {
|
||||
setDraftEnd(Math.floor(Date.now() / 1000));
|
||||
setActiveField("start");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("usage.liveEndTime", "结束时间跟随当前时刻")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
@@ -342,7 +403,7 @@ export function UsageDateRangePicker({
|
||||
</div>
|
||||
|
||||
{/* Right: calendar */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-2.5 sm:min-w-0 sm:flex-1">
|
||||
<div className="usage-range-calendar rounded-lg border border-border/50 bg-muted/30 p-2.5">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Button
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ClaudeDesktopProviderPreset {
|
||||
apiKeyUrl?: string;
|
||||
category?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
baseUrl: string;
|
||||
@@ -414,6 +415,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://api.moonshot.cn/anthropic",
|
||||
@@ -429,6 +431,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
@@ -897,17 +900,17 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "CTok.ai",
|
||||
websiteUrl: "https://ctok.ai",
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
name: "ETok.ai",
|
||||
websiteUrl: "https://etok.ai",
|
||||
apiKeyUrl: "https://etok.ai",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.ctok.ai",
|
||||
baseUrl: "https://api.etok.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ctok",
|
||||
icon: "ctok",
|
||||
partnerPromotionKey: "etok",
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ProviderPreset {
|
||||
settingsConfig: object;
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
isPartner?: boolean; // 标识是否为商业合作伙伴
|
||||
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
|
||||
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
// 新增:指定该预设所使用的 API Key 字段名(默认 ANTHROPIC_AUTH_TOKEN)
|
||||
@@ -367,6 +368,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
@@ -384,11 +386,21 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "${CLAUDE_CODE_AUTO_COMPACT_WINDOW}",
|
||||
},
|
||||
},
|
||||
templateValues: {
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: {
|
||||
label: "Auto Compact Window",
|
||||
placeholder: "262144",
|
||||
defaultValue: "262144",
|
||||
editorValue: "262144",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -971,19 +983,19 @@ export const providerPresets: ProviderPreset[] = [
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "CTok.ai",
|
||||
websiteUrl: "https://ctok.ai",
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
name: "ETok.ai",
|
||||
websiteUrl: "https://etok.ai",
|
||||
apiKeyUrl: "https://etok.ai",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.ctok.ai",
|
||||
ANTHROPIC_BASE_URL: "https://api.etok.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "ctok", // 促销信息 i18n key
|
||||
icon: "ctok",
|
||||
partnerPromotionKey: "etok", // 促销信息 i18n key
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface CodexProviderPreset {
|
||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
isPartner?: boolean; // 标识是否为商业合作伙伴
|
||||
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
|
||||
partnerPromotionKey?: string; // 合作伙伴促销信息的 i18n key
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
isCustomTemplate?: boolean; // 标识是否为自定义模板
|
||||
@@ -420,6 +421,7 @@ requires_openai_auth = true`,
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
@@ -450,6 +452,7 @@ requires_openai_auth = true`,
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/",
|
||||
apiKeyUrl: "https://www.kimi.com/code/",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
@@ -1219,20 +1222,20 @@ requires_openai_auth = true`,
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "CTok.ai",
|
||||
websiteUrl: "https://ctok.ai",
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
name: "ETok.ai",
|
||||
websiteUrl: "https://etok.ai",
|
||||
apiKeyUrl: "https://etok.ai",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"ctok",
|
||||
"https://api.ctok.ai/v1",
|
||||
"etok",
|
||||
"https://api.etok.ai/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.ctok.ai/v1"],
|
||||
endpointCandidates: ["https://api.etok.ai/v1"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "ctok", // 促销信息 i18n key
|
||||
icon: "ctok",
|
||||
partnerPromotionKey: "etok", // 促销信息 i18n key
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TEMPLATE_TYPES } from "@/config/constants";
|
||||
|
||||
export interface CodingPlanProviderEntry {
|
||||
/** 与后端 QuotaTier 的 `codingPlanProvider` 取值对齐 */
|
||||
id: "kimi" | "zhipu" | "minimax" | "zenmux";
|
||||
id: "kimi" | "zhipu" | "minimax" | "zenmux" | "volcengine";
|
||||
/** UsageScriptModal 下拉显示用 */
|
||||
label: string;
|
||||
/** base_url 匹配规则 */
|
||||
@@ -35,6 +35,14 @@ export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [
|
||||
label: "ZenMux",
|
||||
pattern: /zenmux\./i,
|
||||
},
|
||||
{
|
||||
// 火山方舟 Agent Plan / Coding Plan。base_url 形如
|
||||
// ark.cn-beijing.volces.com/api/coding[/v3];与后端 detect_provider 的
|
||||
// `volces.com/api/coding` 子串判断同效。
|
||||
id: "volcengine",
|
||||
label: "火山方舟 (Volcengine)",
|
||||
pattern: /volces\.com\/api\/coding/i,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 根据 Base URL 自动检测 Coding Plan 供应商;未命中返回 null */
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface GeminiProviderPreset {
|
||||
description?: string;
|
||||
category?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
|
||||
partnerPromotionKey?: string;
|
||||
endpointCandidates?: string[];
|
||||
theme?: GeminiPresetTheme;
|
||||
@@ -280,23 +281,23 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "CTok.ai",
|
||||
websiteUrl: "https://ctok.ai",
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
name: "ETok.ai",
|
||||
websiteUrl: "https://etok.ai",
|
||||
apiKeyUrl: "https://etok.ai",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.ctok.ai/v1beta",
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.etok.ai/v1beta",
|
||||
GEMINI_MODEL: "gemini-3.5-flash",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.ctok.ai/v1beta",
|
||||
baseURL: "https://api.etok.ai/v1beta",
|
||||
model: "gemini-3.5-flash",
|
||||
description: "CTok",
|
||||
description: "ETok",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ctok",
|
||||
endpointCandidates: ["https://api.ctok.ai/v1beta"],
|
||||
icon: "ctok",
|
||||
partnerPromotionKey: "etok",
|
||||
endpointCandidates: ["https://api.etok.ai/v1beta"],
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface HermesProviderPreset {
|
||||
settingsConfig: HermesProviderSettingsConfig;
|
||||
isOfficial?: boolean;
|
||||
isPartner?: boolean;
|
||||
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
|
||||
partnerPromotionKey?: string;
|
||||
category?: ProviderCategory;
|
||||
templateValues?: Record<string, TemplateValueConfig>;
|
||||
@@ -515,6 +516,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
name: "kimi",
|
||||
@@ -532,6 +534,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
name: "kimi_coding",
|
||||
@@ -1192,12 +1195,12 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CTok.ai",
|
||||
websiteUrl: "https://ctok.ai",
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
name: "ETok.ai",
|
||||
websiteUrl: "https://etok.ai",
|
||||
apiKeyUrl: "https://etok.ai",
|
||||
settingsConfig: {
|
||||
name: "ctok",
|
||||
base_url: "https://api.ctok.ai",
|
||||
name: "etok",
|
||||
base_url: "https://api.etok.ai",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
@@ -1208,11 +1211,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ctok",
|
||||
icon: "ctok",
|
||||
partnerPromotionKey: "etok",
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "ctok" },
|
||||
model: { default: "claude-opus-4-8", provider: "etok" },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface OpenClawProviderPreset {
|
||||
settingsConfig: OpenClawProviderConfig;
|
||||
isOfficial?: boolean;
|
||||
isPartner?: boolean;
|
||||
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
|
||||
partnerPromotionKey?: string;
|
||||
category?: ProviderCategory;
|
||||
/** Template variable definitions */
|
||||
@@ -490,7 +491,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi K2.7 Code",
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
@@ -529,6 +531,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
settingsConfig: {
|
||||
@@ -2074,11 +2077,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CTok.ai",
|
||||
websiteUrl: "https://ctok.ai",
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
name: "ETok.ai",
|
||||
websiteUrl: "https://etok.ai",
|
||||
apiKeyUrl: "https://etok.ai",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.ctok.ai",
|
||||
baseUrl: "https://api.etok.ai",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
@@ -2092,8 +2095,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ctok",
|
||||
icon: "ctok",
|
||||
partnerPromotionKey: "etok",
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
@@ -2104,10 +2107,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "ctok/claude-opus-4-8",
|
||||
primary: "etok/claude-opus-4-8",
|
||||
},
|
||||
modelCatalog: {
|
||||
"ctok/claude-opus-4-8": { alias: "Opus" },
|
||||
"etok/claude-opus-4-8": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface OpenCodeProviderPreset {
|
||||
settingsConfig: OpenCodeProviderConfig;
|
||||
isOfficial?: boolean;
|
||||
isPartner?: boolean;
|
||||
primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形
|
||||
partnerPromotionKey?: string;
|
||||
category?: ProviderCategory;
|
||||
templateValues?: Record<string, TemplateValueConfig>;
|
||||
@@ -588,12 +589,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Kimi K2.7 Code",
|
||||
name: "Kimi",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://platform.moonshot.cn/console?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Kimi K2.7 Code",
|
||||
name: "Kimi",
|
||||
options: {
|
||||
baseURL: "https://api.moonshot.cn/v1",
|
||||
apiKey: "",
|
||||
@@ -622,6 +624,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
primePartner: true,
|
||||
websiteUrl: "https://www.kimi.com/code/docs/?aff=cc-switch",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys?aff=cc-switch",
|
||||
settingsConfig: {
|
||||
@@ -1651,14 +1654,14 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CTok.ai",
|
||||
websiteUrl: "https://ctok.ai",
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
name: "ETok.ai",
|
||||
websiteUrl: "https://etok.ai",
|
||||
apiKeyUrl: "https://etok.ai",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "CTok",
|
||||
name: "ETok",
|
||||
options: {
|
||||
baseURL: "https://api.ctok.ai/v1",
|
||||
baseURL: "https://api.etok.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
@@ -1669,8 +1672,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ctok",
|
||||
icon: "ctok",
|
||||
partnerPromotionKey: "etok",
|
||||
icon: "etok",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"editProviderHint": "Configuration will be applied to the current provider immediately after update.",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"addNewProvider": "Add New Provider",
|
||||
"addFooterHint": "💡 After choosing a preset, fill in the fields below (e.g. API Key)",
|
||||
"addClaudeProvider": "Add Claude Code Provider",
|
||||
"addCodexProvider": "Add Codex Provider",
|
||||
"addGeminiProvider": "Add Gemini Provider",
|
||||
@@ -753,7 +754,7 @@
|
||||
"appConfigDirDescription": "Customize the storage location for CC Switch configuration (point to cloud sync folder to enable config sync)",
|
||||
"browsePlaceholderApp": "e.g., C:\\Users\\Administrator\\.cc-switch",
|
||||
"claudeConfigDir": "Claude Code Configuration Directory",
|
||||
"claudeConfigDirDescription": "Override Claude configuration directory (settings.json) and keep claude.json (MCP) alongside it.",
|
||||
"claudeConfigDirDescription": "Override Claude configuration directory (settings.json). Custom directories store .claude.json (MCP) inside the directory; the default ~/.claude directory keeps Claude Code's split ~/.claude.json path.",
|
||||
"codexConfigDir": "Codex Configuration Directory",
|
||||
"codexConfigDirDescription": "Override Codex configuration directory.",
|
||||
"geminiConfigDir": "Gemini Configuration Directory",
|
||||
@@ -1015,7 +1016,7 @@
|
||||
"siliconflow": "SiliconFlow is an official partner of CC Switch",
|
||||
"ucloud": "Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!",
|
||||
"micu": "Micu is an official partner of CC Switch",
|
||||
"ctok": "Join the CTok community on the official website and subscribe to a plan.",
|
||||
"etok": "Join the ETok community on the official website and subscribe to a plan.",
|
||||
"shengsuanyun": "Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!",
|
||||
"doubaoseed": "DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.",
|
||||
"volcengine_agentplan": "Volcengine Ark Coding Plan offers exclusive benefits for CC Switch users: subscribe via this link and new customers get 75% off for the first two months, plus an extra 5% off with the exclusive invite code 6J6FV5N2 — as low as ¥9.4/month!",
|
||||
@@ -1472,6 +1473,8 @@
|
||||
"customRangeHint": "Supports both date and time",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"liveEndTime": "End time follows current time",
|
||||
"liveEndTimeNow": "Now",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Creation",
|
||||
@@ -1509,6 +1512,20 @@
|
||||
"editPricing": "Edit Pricing",
|
||||
"pricingAdded": "Pricing added",
|
||||
"pricingUpdated": "Pricing updated",
|
||||
"importFromModelsDev": "Import from models.dev",
|
||||
"modelsDevHint": "Skip manual entry — pick model pricing from models.dev",
|
||||
"modelsDevPickerTitle": "Import Pricing from models.dev",
|
||||
"modelsDevPickerDesc": "Select a model to import (prices in USD per million tokens). One model per import.",
|
||||
"modelsDevSearchPlaceholder": "Search models or providers (full search)...",
|
||||
"modelsDevAllProviders": "All providers",
|
||||
"modelsDevLoadError": "Failed to load models.dev data",
|
||||
"modelsDevRetry": "Retry",
|
||||
"modelsDevImportButton": "Import",
|
||||
"modelsDevImporting": "Importing...",
|
||||
"modelsDevImported": "Imported pricing for {{name}}",
|
||||
"modelsDevNoResults": "No matching models",
|
||||
"modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search",
|
||||
"modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all",
|
||||
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
|
||||
},
|
||||
@@ -1542,6 +1559,9 @@
|
||||
"accessTokenPlaceholder": "Generate in 'Security Settings'",
|
||||
"userId": "User ID",
|
||||
"userIdPlaceholder": "e.g., 114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "Volcengine usage query needs an account-level AccessKey ID / Secret (different from the inference API key). Create them in the Volcengine console via the top-right account menu → 'API Access Keys'.",
|
||||
"defaultPlan": "Default Plan",
|
||||
"queryFailedMessage": "Query failed",
|
||||
"queryScript": "Query script (JavaScript)",
|
||||
@@ -2841,6 +2861,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"copilotPremium": "Premium",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
"addNewProvider": "新しいプロバイダーを追加",
|
||||
"addFooterHint": "💡 プリセットを選んだら、下のフィールド(API Key など)を入力してください",
|
||||
"addClaudeProvider": "Claude Code プロバイダーを追加",
|
||||
"addCodexProvider": "Codex プロバイダーを追加",
|
||||
"addGeminiProvider": "Gemini プロバイダーを追加",
|
||||
@@ -753,7 +754,7 @@
|
||||
"appConfigDirDescription": "CC Switch の保存場所をカスタマイズします(クラウド同期フォルダを指定すると設定を同期できます)",
|
||||
"browsePlaceholderApp": "例: C:\\\\Users\\\\Administrator\\\\.cc-switch",
|
||||
"claudeConfigDir": "Claude Code 設定ディレクトリ",
|
||||
"claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きし、claude.json(MCP)も同じ場所に置きます。",
|
||||
"claudeConfigDirDescription": "Claude の設定ディレクトリ(settings.json)を上書きします。カスタムディレクトリでは .claude.json(MCP)をその中に保存し、既定の ~/.claude では Claude Code の既定どおり ~/.claude.json を使います。",
|
||||
"codexConfigDir": "Codex 設定ディレクトリ",
|
||||
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
|
||||
"geminiConfigDir": "Gemini 設定ディレクトリ",
|
||||
@@ -1015,7 +1016,7 @@
|
||||
"siliconflow": "SiliconFlow は CC Switch の公式パートナーです",
|
||||
"ucloud": "Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!",
|
||||
"micu": "Micu は CC Switch の公式パートナーです",
|
||||
"ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。",
|
||||
"etok": "公式サイトで ETok コミュニティに参加し、プランを購読してください。",
|
||||
"shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!",
|
||||
"doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。",
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟 Coding Plan を購読すると、新規のお客様は最初の 2 か月が 75% オフ、さらに専用招待コード 6J6FV5N2 で 5% オフが加算され、月額 9.4 元から!",
|
||||
@@ -1472,6 +1473,8 @@
|
||||
"customRangeHint": "日付と時刻の両方に対応",
|
||||
"startTime": "開始時刻",
|
||||
"endTime": "終了時刻",
|
||||
"liveEndTime": "終了時刻を現在時刻に追従",
|
||||
"liveEndTimeNow": "現在",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "作成",
|
||||
@@ -1509,6 +1512,20 @@
|
||||
"editPricing": "価格設定を編集",
|
||||
"pricingAdded": "価格設定が追加されました",
|
||||
"pricingUpdated": "価格設定が更新されました",
|
||||
"importFromModelsDev": "models.dev からインポート",
|
||||
"modelsDevHint": "手動入力の代わりに、models.dev からモデル価格を選択できます",
|
||||
"modelsDevPickerTitle": "models.dev から価格をインポート",
|
||||
"modelsDevPickerDesc": "インポートするモデルを選択してください(価格単位:USD / 100万トークン)。1回につき1モデルです。",
|
||||
"modelsDevSearchPlaceholder": "モデルまたはプロバイダーを検索(全件検索)...",
|
||||
"modelsDevAllProviders": "すべてのプロバイダー",
|
||||
"modelsDevLoadError": "models.dev データの読み込みに失敗しました",
|
||||
"modelsDevRetry": "再試行",
|
||||
"modelsDevImportButton": "インポート",
|
||||
"modelsDevImporting": "インポート中...",
|
||||
"modelsDevImported": "{{name}} の価格をインポートしました",
|
||||
"modelsDevNoResults": "一致するモデルがありません",
|
||||
"modelsDevTruncated": "{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください",
|
||||
"modelsDevDefaultHint": "最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます",
|
||||
"cacheReadCostPerMillion": "キャッシュ読み取りコスト(100万トークンあたり、USD)",
|
||||
"cacheCreationCostPerMillion": "キャッシュ書き込みコスト(100万トークンあたり、USD)"
|
||||
},
|
||||
@@ -1542,6 +1559,9 @@
|
||||
"accessTokenPlaceholder": "「Security Settings」で生成",
|
||||
"userId": "ユーザー ID",
|
||||
"userIdPlaceholder": "例: 114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "Volcengine の使用量照会にはアカウントレベルの AccessKey ID / Secret が必要です(推論 API キーとは別物)。Volcengine コンソール右上のアカウントメニュー →「API アクセスキー」で作成してください。",
|
||||
"defaultPlan": "デフォルトプラン",
|
||||
"queryFailedMessage": "照会に失敗しました",
|
||||
"queryScript": "照会スクリプト (JavaScript)",
|
||||
@@ -2841,6 +2861,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "週間",
|
||||
"monthly": "月間",
|
||||
"copilotPremium": "プレミアム",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}後にリセット",
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"editProviderHint": "更新設定後將立即套用至目前供應商。",
|
||||
"deleteProvider": "刪除供應商",
|
||||
"addNewProvider": "新增供應商",
|
||||
"addFooterHint": "💡 選擇預設後,請在下方填寫 API Key 等欄位",
|
||||
"addClaudeProvider": "新增 Claude Code 供應商",
|
||||
"addCodexProvider": "新增 Codex 供應商",
|
||||
"addGeminiProvider": "新增 Gemini 供應商",
|
||||
@@ -753,7 +754,7 @@
|
||||
"appConfigDirDescription": "自訂 CC Switch 的設定儲存位置(指定至雲端同步資料夾即可雲端同步設定)",
|
||||
"browsePlaceholderApp": "例如:C:\\Users\\Administrator\\.cc-switch",
|
||||
"claudeConfigDir": "Claude Code 設定目錄",
|
||||
"claudeConfigDirDescription": "覆寫 Claude 設定目錄 (settings.json),同時會在同級存放 Claude MCP 的 claude.json。",
|
||||
"claudeConfigDirDescription": "覆寫 Claude 設定目錄 (settings.json)。真正自訂目錄會在目錄內存放 Claude MCP 的 .claude.json;預設 ~/.claude 目錄仍使用 Claude 預設的 ~/.claude.json。",
|
||||
"codexConfigDir": "Codex 設定目錄",
|
||||
"codexConfigDirDescription": "覆寫 Codex 設定目錄。",
|
||||
"geminiConfigDir": "Gemini 設定目錄",
|
||||
@@ -987,7 +988,7 @@
|
||||
"siliconflow": "矽基流動是 CC Switch 的官方合作夥伴",
|
||||
"ucloud": "優雲智算為 CC Switch 的使用者提供了特殊優惠,透過此連結註冊,可以獲得 5 元平台體驗金!",
|
||||
"micu": "Micu 是 CC Switch 的官方合作夥伴",
|
||||
"ctok": "官網加入 CTok 社群,訂閱方案。",
|
||||
"etok": "官網加入 ETok 社群,訂閱方案。",
|
||||
"shengsuanyun": "勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!",
|
||||
"doubaoseed": "豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度",
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 Coding Plan,新客戶首兩個月享 2.5 折優惠,再用專屬邀請碼 6J6FV5N2 領取獎勵疊加 9.5 折,低至 9.4 元/月!",
|
||||
@@ -1444,6 +1445,8 @@
|
||||
"customRangeHint": "支援日期與時間",
|
||||
"startTime": "開始時間",
|
||||
"endTime": "結束時間",
|
||||
"liveEndTime": "結束時間跟隨當前時刻",
|
||||
"liveEndTimeNow": "現在",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "建立",
|
||||
@@ -1481,6 +1484,20 @@
|
||||
"editPricing": "編輯定價",
|
||||
"pricingAdded": "定價已新增",
|
||||
"pricingUpdated": "定價已更新",
|
||||
"importFromModelsDev": "從 models.dev 匯入",
|
||||
"modelsDevHint": "無需手動填寫,可從 models.dev 選擇模型定價",
|
||||
"modelsDevPickerTitle": "從 models.dev 匯入定價",
|
||||
"modelsDevPickerDesc": "選擇要匯入的模型(價格單位:USD / 百萬 tokens),每次匯入一個",
|
||||
"modelsDevSearchPlaceholder": "搜尋模型或供應商(全量搜尋)...",
|
||||
"modelsDevAllProviders": "全部供應商",
|
||||
"modelsDevLoadError": "載入 models.dev 資料失敗",
|
||||
"modelsDevRetry": "重試",
|
||||
"modelsDevImportButton": "匯入",
|
||||
"modelsDevImporting": "匯入中...",
|
||||
"modelsDevImported": "已匯入 {{name}} 的定價",
|
||||
"modelsDevNoResults": "沒有符合的模型",
|
||||
"modelsDevTruncated": "僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍",
|
||||
"modelsDevDefaultHint": "預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋",
|
||||
"cacheReadCostPerMillion": "快取讀取成本 (每百萬 tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "快取寫入成本 (每百萬 tokens, USD)"
|
||||
},
|
||||
@@ -1514,6 +1531,9 @@
|
||||
"accessTokenPlaceholder": "在「安全設定」裡產生",
|
||||
"userId": "使用者 ID",
|
||||
"userIdPlaceholder": "例如:114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "火山用量查詢需帳號級 AccessKey ID / Secret(與推理 API Key 不同)。請在火山引擎主控台右上角帳號選單 →「API存取金鑰」中建立。",
|
||||
"defaultPlan": "預設方案",
|
||||
"queryFailedMessage": "查詢失敗",
|
||||
"queryScript": "查詢腳本(JavaScript)",
|
||||
@@ -2813,6 +2833,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每週",
|
||||
"monthly": "每月",
|
||||
"copilotPremium": "進階請求",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}} 後重設",
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"editProviderHint": "更新配置后将立即应用到当前供应商。",
|
||||
"deleteProvider": "删除供应商",
|
||||
"addNewProvider": "添加新供应商",
|
||||
"addFooterHint": "💡 选择预设后,请在下方填写 API Key 等字段",
|
||||
"addClaudeProvider": "添加 Claude Code 供应商",
|
||||
"addCodexProvider": "添加 Codex 供应商",
|
||||
"addGeminiProvider": "添加 Gemini 供应商",
|
||||
@@ -753,7 +754,7 @@
|
||||
"appConfigDirDescription": "自定义 CC Switch 的配置存储位置(指定到云同步文件夹即可云同步配置)",
|
||||
"browsePlaceholderApp": "例如:C:\\Users\\Administrator\\.cc-switch",
|
||||
"claudeConfigDir": "Claude Code 配置目录",
|
||||
"claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json),同时会在同级存放 Claude MCP 的 claude.json。",
|
||||
"claudeConfigDirDescription": "覆盖 Claude 配置目录 (settings.json)。真正自定义目录会在目录内存放 Claude MCP 的 .claude.json;默认 ~/.claude 目录仍使用 Claude 默认的 ~/.claude.json。",
|
||||
"codexConfigDir": "Codex 配置目录",
|
||||
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
|
||||
"geminiConfigDir": "Gemini 配置目录",
|
||||
@@ -1015,7 +1016,7 @@
|
||||
"siliconflow": "硅基流动是 CC Switch 的官方合作伙伴",
|
||||
"ucloud": "优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!",
|
||||
"micu": "Micu 是 CC Switch 的官方合作伙伴",
|
||||
"ctok": "官网加入CTok社群,订阅套餐。",
|
||||
"etok": "官网加入ETok社群,订阅套餐。",
|
||||
"shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
|
||||
"doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度",
|
||||
"volcengine_agentplan": "火山方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!",
|
||||
@@ -1472,6 +1473,8 @@
|
||||
"customRangeHint": "支持日期与时间",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"liveEndTime": "结束时间跟随当前时刻",
|
||||
"liveEndTimeNow": "现在",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "创建",
|
||||
@@ -1509,6 +1512,20 @@
|
||||
"editPricing": "编辑定价",
|
||||
"pricingAdded": "定价已添加",
|
||||
"pricingUpdated": "定价已更新",
|
||||
"importFromModelsDev": "从 models.dev 导入",
|
||||
"modelsDevHint": "无需手动填写,可从 models.dev 选择模型定价",
|
||||
"modelsDevPickerTitle": "从 models.dev 导入定价",
|
||||
"modelsDevPickerDesc": "选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个",
|
||||
"modelsDevSearchPlaceholder": "搜索模型或供应商(全量搜索)...",
|
||||
"modelsDevAllProviders": "全部供应商",
|
||||
"modelsDevLoadError": "加载 models.dev 数据失败",
|
||||
"modelsDevRetry": "重试",
|
||||
"modelsDevImportButton": "导入",
|
||||
"modelsDevImporting": "导入中...",
|
||||
"modelsDevImported": "已导入 {{name}} 的定价",
|
||||
"modelsDevNoResults": "没有匹配的模型",
|
||||
"modelsDevTruncated": "仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
|
||||
"modelsDevDefaultHint": "默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
|
||||
"cacheReadCostPerMillion": "缓存读取成本 (每百万 tokens, USD)",
|
||||
"cacheCreationCostPerMillion": "缓存写入成本 (每百万 tokens, USD)"
|
||||
},
|
||||
@@ -1542,6 +1559,9 @@
|
||||
"accessTokenPlaceholder": "在'安全设置'里生成",
|
||||
"userId": "用户 ID",
|
||||
"userIdPlaceholder": "例如:114514",
|
||||
"accessKeyId": "AccessKey ID",
|
||||
"secretAccessKey": "SecretAccessKey",
|
||||
"volcengineAkSkHint": "火山用量查询需账号级 AccessKey ID / Secret(与推理 API Key 不同)。请在火山引擎控制台右上角账号菜单 →「API访问密钥」中创建。",
|
||||
"defaultPlan": "默认套餐",
|
||||
"queryFailedMessage": "查询失败",
|
||||
"queryScript": "查询脚本(JavaScript)",
|
||||
@@ -2841,6 +2861,7 @@
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"weeklyLimit": "每周",
|
||||
"monthly": "每月",
|
||||
"copilotPremium": "高级请求",
|
||||
"utilization": "{{value}}%",
|
||||
"resetsIn": "{{time}}后重置",
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<!-- 圆角方形背景 -->
|
||||
<rect x="0" y="0" width="200" height="200" rx="40" ry="40" fill="#3B82F6"/>
|
||||
|
||||
<!-- 中心白色圆环 -->
|
||||
<circle cx="100" cy="100" r="45" fill="white"/>
|
||||
<circle cx="100" cy="100" r="25" fill="#3B82F6"/>
|
||||
|
||||
<!-- 装饰小圆点 -->
|
||||
<circle cx="50" cy="50" r="6" fill="white" opacity="0.6"/>
|
||||
<circle cx="165" cy="70" r="5" fill="white" opacity="0.5"/>
|
||||
<circle cx="170" cy="140" r="7" fill="white" opacity="0.4"/>
|
||||
|
||||
<!-- 右下角深蓝色装饰 -->
|
||||
<defs>
|
||||
<clipPath id="roundedCorner">
|
||||
<rect x="0" y="0" width="200" height="200" rx="40" ry="40"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d="M 200 150 Q 175 175 150 200 L 200 200 Z" fill="#2563EB" opacity="0.6" clip-path="url(#roundedCorner)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 838 B |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
File diff suppressed because one or more lines are too long
@@ -202,12 +202,12 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
ctok: {
|
||||
name: "ctok",
|
||||
displayName: "CTok",
|
||||
etok: {
|
||||
name: "etok",
|
||||
displayName: "ETok",
|
||||
category: "ai-provider",
|
||||
keywords: ["ctok", "ai", "programming"],
|
||||
defaultColor: "#3B82F6",
|
||||
keywords: ["etok", "ai", "programming"],
|
||||
defaultColor: "#F97316",
|
||||
},
|
||||
cubence: {
|
||||
name: "cubence",
|
||||
@@ -305,7 +305,7 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
displayName: "Kimi",
|
||||
category: "ai-provider",
|
||||
keywords: ["moonshot"],
|
||||
defaultColor: "#6366F1",
|
||||
defaultColor: "#1783FF",
|
||||
},
|
||||
meta: {
|
||||
name: "meta",
|
||||
|
||||
@@ -191,3 +191,36 @@ input[type="password"]::-ms-reveal,
|
||||
input[type="password"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Usage date-range picker popover.
|
||||
*
|
||||
* The two-column layout (date fields | calendar) must depend on how much
|
||||
* width the popover itself actually has — not on the viewport. The popover is
|
||||
* clamped to `100vw - 2rem` and anchored to its trigger, so a viewport-based
|
||||
* breakpoint (e.g. Tailwind `sm:`) could enable the wide layout while the
|
||||
* popover only had room for one column, pushing the calendar off-screen.
|
||||
*
|
||||
* A container query keys the layout to the popover's own inline size. The base
|
||||
* (stacked) layout is the safe fallback when container queries are
|
||||
* unsupported, keeping the calendar fully visible at any width.
|
||||
*/
|
||||
.usage-range-popover {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
.usage-range-layout {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.usage-range-fields {
|
||||
width: 250px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.usage-range-calendar {
|
||||
min-width: 0;
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,16 @@ export const subscriptionApi = {
|
||||
getCodingPlanQuota: (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
// 火山方舟用账号 AK/SK 签名查询用量;其他供应商不传。
|
||||
accessKeyId?: string,
|
||||
secretAccessKey?: string,
|
||||
): Promise<SubscriptionQuota> =>
|
||||
invoke("get_coding_plan_quota", { baseUrl, apiKey }),
|
||||
invoke("get_coding_plan_quota", {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
}),
|
||||
getBalance: (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
|
||||
+19
-1
@@ -26,6 +26,7 @@ type RequestLogsKey = {
|
||||
preset: UsageRangeSelection["preset"];
|
||||
customStartDate?: number;
|
||||
customEndDate?: number;
|
||||
liveEndTime?: boolean;
|
||||
appType?: string;
|
||||
providerName?: string;
|
||||
model?: string;
|
||||
@@ -40,6 +41,7 @@ export const usageKeys = {
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
filters?: UsageScopeFilters,
|
||||
liveEndTime?: boolean,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -47,6 +49,7 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
liveEndTime ?? false,
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
@@ -55,7 +58,8 @@ export const usageKeys = {
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
filters?: UsageScopeFilters,
|
||||
filters?: Pick<UsageScopeFilters, "providerName" | "model">,
|
||||
liveEndTime?: boolean,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -63,6 +67,7 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
liveEndTime ?? false,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
] as const,
|
||||
@@ -71,6 +76,7 @@ export const usageKeys = {
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
filters?: UsageScopeFilters,
|
||||
liveEndTime?: boolean,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -78,6 +84,7 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
liveEndTime ?? false,
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
@@ -87,6 +94,7 @@ export const usageKeys = {
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
filters?: UsageScopeFilters,
|
||||
liveEndTime?: boolean,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -94,6 +102,7 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
liveEndTime ?? false,
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
@@ -103,6 +112,7 @@ export const usageKeys = {
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
filters?: UsageScopeFilters,
|
||||
liveEndTime?: boolean,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
@@ -110,6 +120,7 @@ export const usageKeys = {
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
liveEndTime ?? false,
|
||||
filters?.appType ?? null,
|
||||
filters?.providerName ?? null,
|
||||
filters?.model ?? null,
|
||||
@@ -121,6 +132,7 @@ export const usageKeys = {
|
||||
key.preset,
|
||||
key.customStartDate ?? 0,
|
||||
key.customEndDate ?? 0,
|
||||
key.liveEndTime ?? false,
|
||||
key.appType ?? "",
|
||||
key.providerName ?? "",
|
||||
key.model ?? "",
|
||||
@@ -159,6 +171,7 @@ export function useUsageSummary(
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
effective,
|
||||
range.liveEndTime,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
@@ -186,6 +199,7 @@ export function useUsageSummaryByApp(
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
filters,
|
||||
range.liveEndTime,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
@@ -213,6 +227,7 @@ export function useUsageTrends(
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
effective,
|
||||
range.liveEndTime,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
@@ -241,6 +256,7 @@ export function useProviderStats(
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
effective,
|
||||
range.liveEndTime,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
@@ -269,6 +285,7 @@ export function useModelStats(
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
effective,
|
||||
range.liveEndTime,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
@@ -296,6 +313,7 @@ export function useRequestLogs({
|
||||
preset: range.preset,
|
||||
customStartDate: range.customStartDate,
|
||||
customEndDate: range.customEndDate,
|
||||
liveEndTime: range.liveEndTime,
|
||||
appType: filters.appType,
|
||||
providerName: filters.providerName,
|
||||
model: filters.model,
|
||||
|
||||
@@ -49,7 +49,9 @@ export function resolveUsageRange(
|
||||
};
|
||||
case "custom": {
|
||||
const startDate = selection.customStartDate ?? endDate - DAY_SECONDS;
|
||||
const customEndDate = selection.customEndDate ?? endDate;
|
||||
const customEndDate = selection.liveEndTime
|
||||
? endDate
|
||||
: (selection.customEndDate ?? endDate);
|
||||
return {
|
||||
startDate,
|
||||
endDate: customEndDate,
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface UsageScript {
|
||||
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
|
||||
accessToken?: string; // 访问令牌(NewAPI 模板使用)
|
||||
userId?: string; // 用户ID(NewAPI 模板使用)
|
||||
accessKeyId?: string; // 火山方舟 AccessKey ID(用量查询签名用,与推理 Key 分离)
|
||||
secretAccessKey?: string; // 火山方舟 SecretAccessKey
|
||||
codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax")
|
||||
autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用)
|
||||
autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段
|
||||
|
||||
@@ -152,6 +152,9 @@ export interface UsageRangeSelection {
|
||||
preset: UsageRangePreset;
|
||||
customStartDate?: number;
|
||||
customEndDate?: number;
|
||||
/** When true (custom mode only), endDate resolves to "now" instead of the
|
||||
* fixed customEndDate snapshot, and the end-time field becomes read-only. */
|
||||
liveEndTime?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -400,7 +400,7 @@ export const hasTomlCommonConfigSnippet = (
|
||||
|
||||
const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
|
||||
const TOML_BASE_URL_PATTERN =
|
||||
/^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
|
||||
/^\s*base_url\s*=\s*(?:"((?:\\.|[^"\\\r\n])*)"|'([^'\r\n]*)')\s*(?:#.*)?$/;
|
||||
const TOML_EXPERIMENTAL_BEARER_TOKEN_PATTERN =
|
||||
/^\s*experimental_bearer_token\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
|
||||
const TOML_EXPERIMENTAL_BEARER_TOKEN_REPLACE_PATTERN =
|
||||
@@ -553,11 +553,12 @@ const findTomlAssignmentInRange = (
|
||||
): TomlAssignmentMatch | undefined => {
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const match = lines[index].match(pattern);
|
||||
if (match?.[2]) {
|
||||
const value = match?.[2] ?? match?.[1];
|
||||
if (value) {
|
||||
return {
|
||||
index,
|
||||
sectionName,
|
||||
value: match[2],
|
||||
value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -565,6 +566,26 @@ const findTomlAssignmentInRange = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const findTomlAssignmentsInRange = (
|
||||
lines: string[],
|
||||
pattern: RegExp,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
sectionName?: string,
|
||||
): TomlAssignmentMatch[] => {
|
||||
const matches: TomlAssignmentMatch[] = [];
|
||||
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const match = lines[index].match(pattern);
|
||||
const value = match?.[2] ?? match?.[1];
|
||||
if (value) {
|
||||
matches.push({ index, sectionName, value });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const findTomlLineInRange = (
|
||||
lines: string[],
|
||||
pattern: RegExp,
|
||||
@@ -595,14 +616,15 @@ const findTomlAssignments = (
|
||||
}
|
||||
|
||||
const match = line.match(pattern);
|
||||
if (!match?.[2]) {
|
||||
const value = match?.[2] ?? match?.[1];
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
assignments.push({
|
||||
index,
|
||||
sectionName: currentSectionName,
|
||||
value: match[2],
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1228,18 +1250,20 @@ export const setCodexBaseUrl = (
|
||||
|
||||
if (targetSectionName) {
|
||||
const sectionRange = getTomlSectionRange(lines, targetSectionName);
|
||||
const targetMatch = sectionRange
|
||||
? findTomlAssignmentInRange(
|
||||
const targetMatches = sectionRange
|
||||
? findTomlAssignmentsInRange(
|
||||
lines,
|
||||
TOML_BASE_URL_PATTERN,
|
||||
sectionRange.bodyStartIndex,
|
||||
sectionRange.bodyEndIndex,
|
||||
targetSectionName,
|
||||
)
|
||||
: undefined;
|
||||
: [];
|
||||
|
||||
if (targetMatch) {
|
||||
lines.splice(targetMatch.index, 1);
|
||||
if (targetMatches.length > 0) {
|
||||
for (const match of [...targetMatches].reverse()) {
|
||||
lines.splice(match.index, 1);
|
||||
}
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
}
|
||||
@@ -1257,18 +1281,22 @@ export const setCodexBaseUrl = (
|
||||
|
||||
if (targetSectionName) {
|
||||
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
|
||||
const targetMatch = targetSectionRange
|
||||
? findTomlAssignmentInRange(
|
||||
const targetMatches = targetSectionRange
|
||||
? findTomlAssignmentsInRange(
|
||||
lines,
|
||||
TOML_BASE_URL_PATTERN,
|
||||
targetSectionRange.bodyStartIndex,
|
||||
targetSectionRange.bodyEndIndex,
|
||||
targetSectionName,
|
||||
)
|
||||
: undefined;
|
||||
: [];
|
||||
|
||||
if (targetMatch) {
|
||||
lines[targetMatch.index] = replacementLine;
|
||||
if (targetMatches.length > 0) {
|
||||
const [firstMatch, ...duplicateMatches] = targetMatches;
|
||||
lines[firstMatch.index] = replacementLine;
|
||||
for (const match of [...duplicateMatches].reverse()) {
|
||||
lines.splice(match.index, 1);
|
||||
}
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
@@ -1291,14 +1319,18 @@ export const setCodexBaseUrl = (
|
||||
}
|
||||
|
||||
const topLevelEndIndex = getTopLevelEndIndex(lines);
|
||||
const topLevelMatch = findTomlAssignmentInRange(
|
||||
const topLevelMatches = findTomlAssignmentsInRange(
|
||||
lines,
|
||||
TOML_BASE_URL_PATTERN,
|
||||
0,
|
||||
topLevelEndIndex,
|
||||
);
|
||||
if (topLevelMatch) {
|
||||
lines[topLevelMatch.index] = replacementLine;
|
||||
if (topLevelMatches.length > 0) {
|
||||
const [firstMatch, ...duplicateMatches] = topLevelMatches;
|
||||
lines[firstMatch.index] = replacementLine;
|
||||
for (const match of [...duplicateMatches].reverse()) {
|
||||
lines.splice(match.index, 1);
|
||||
}
|
||||
return finalizeTomlText(lines);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
flattenModels,
|
||||
formatPrice,
|
||||
normalizeModelIdForPricing,
|
||||
} from "@/components/usage/ModelsDevPickerDialog";
|
||||
|
||||
describe("normalizeModelIdForPricing", () => {
|
||||
it("keeps already-normalized ids unchanged", () => {
|
||||
expect(normalizeModelIdForPricing("claude-opus-4-5")).toBe(
|
||||
"claude-opus-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the vendor prefix before the last slash", () => {
|
||||
expect(normalizeModelIdForPricing("z-ai/glm-4.7")).toBe("glm-4.7");
|
||||
expect(normalizeModelIdForPricing("clarifai/main/models/mm-poly-8b")).toBe(
|
||||
"mm-poly-8b",
|
||||
);
|
||||
});
|
||||
|
||||
it("lowercases the id", () => {
|
||||
expect(normalizeModelIdForPricing("MiniMaxAI/MiniMax-M2.1")).toBe(
|
||||
"minimax-m2.1",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates colon suffixes", () => {
|
||||
expect(normalizeModelIdForPricing("claude-sonnet-4-thinking:8192")).toBe(
|
||||
"claude-sonnet-4-thinking",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps @ to -", () => {
|
||||
expect(normalizeModelIdForPricing("claude-sonnet-4@20250514")).toBe(
|
||||
"claude-sonnet-4-20250514",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the [1m] context marker", () => {
|
||||
expect(normalizeModelIdForPricing("claude-sonnet-4-5[1m]")).toBe(
|
||||
"claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("combines all rules", () => {
|
||||
expect(normalizeModelIdForPricing("Vendor/Claude-Sonnet-4@2025:free")).toBe(
|
||||
"claude-sonnet-4-2025",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPrice", () => {
|
||||
it("formats integers without a decimal point", () => {
|
||||
expect(formatPrice(5)).toBe("5");
|
||||
expect(formatPrice(25)).toBe("25");
|
||||
});
|
||||
|
||||
it("trims trailing zeros", () => {
|
||||
expect(formatPrice(0.5)).toBe("0.5");
|
||||
expect(formatPrice(6.25)).toBe("6.25");
|
||||
expect(formatPrice(1.0395)).toBe("1.0395");
|
||||
});
|
||||
|
||||
it("keeps up to six decimal places", () => {
|
||||
expect(formatPrice(0.000001)).toBe("0.000001");
|
||||
expect(formatPrice(0.0000004)).toBe("0");
|
||||
});
|
||||
|
||||
it("returns 0 for zero, negative and non-finite values", () => {
|
||||
expect(formatPrice(0)).toBe("0");
|
||||
expect(formatPrice(-1)).toBe("0");
|
||||
expect(formatPrice(NaN)).toBe("0");
|
||||
expect(formatPrice(Infinity)).toBe("0");
|
||||
});
|
||||
|
||||
it("never produces exponent notation", () => {
|
||||
// 后端 Decimal::from_str 不接受科学计数法
|
||||
expect(formatPrice(1e-8)).toBe("0");
|
||||
expect(formatPrice(1e21)).toBe("0");
|
||||
for (const value of [5, 0.5, 0.000123, 123456.789]) {
|
||||
expect(formatPrice(value)).toMatch(/^\d+(\.\d+)?$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("flattenModels", () => {
|
||||
it("flattens providers, fills defaults and sorts by release date desc", () => {
|
||||
const entries = flattenModels({
|
||||
acme: {
|
||||
id: "acme",
|
||||
name: "Acme AI",
|
||||
models: {
|
||||
"old-model": {
|
||||
id: "old-model",
|
||||
name: "Old Model",
|
||||
release_date: "2024-01-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"new-model": {
|
||||
id: "new-model",
|
||||
name: "New Model",
|
||||
release_date: "2025-06-01",
|
||||
cost: { input: 3, output: 6, cache_read: 0.3, cache_write: 3.75 },
|
||||
},
|
||||
"free-model": {
|
||||
id: "free-model",
|
||||
name: "No Cost Model",
|
||||
},
|
||||
},
|
||||
},
|
||||
bare: {
|
||||
models: {
|
||||
"Vendor/Some-Model:free": {
|
||||
release_date: "2025-01",
|
||||
cost: { input: 0.1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(entries.map((e) => e.key)).toEqual([
|
||||
"acme/new-model",
|
||||
"bare/Vendor/Some-Model:free",
|
||||
"acme/old-model",
|
||||
]);
|
||||
|
||||
const newModel = entries[0];
|
||||
expect(newModel.normalizedId).toBe("new-model");
|
||||
expect(newModel.cacheRead).toBe(0.3);
|
||||
expect(newModel.cacheWrite).toBe(3.75);
|
||||
|
||||
// 没有 name 的 provider 用 id 兜底;缺失的成本字段补 0
|
||||
const bareModel = entries[1];
|
||||
expect(bareModel.providerName).toBe("bare");
|
||||
expect(bareModel.normalizedId).toBe("some-model");
|
||||
expect(bareModel.output).toBe(0);
|
||||
expect(bareModel.cacheRead).toBe(0);
|
||||
|
||||
// 完全没有定价的模型被过滤
|
||||
expect(entries.some((e) => e.modelId === "free-model")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PROVIDER_CARD_TSX = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"components",
|
||||
"providers",
|
||||
"ProviderCard.tsx",
|
||||
);
|
||||
|
||||
describe("ProviderCard layout", () => {
|
||||
const source = fs.readFileSync(PROVIDER_CARD_TSX, "utf8");
|
||||
|
||||
it("lets website links use available card width before truncating", () => {
|
||||
expect(source).not.toContain("max-w-[280px]");
|
||||
expect(source).toContain("flex min-w-0 flex-1 items-center gap-2");
|
||||
expect(source).toContain("min-w-0 flex-1 space-y-1");
|
||||
expect(source).toContain(
|
||||
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TFunction } from "i18next";
|
||||
@@ -60,6 +60,7 @@ type TestPresetEntry = {
|
||||
websiteUrl: string;
|
||||
settingsConfig: Record<string, never>;
|
||||
category: ProviderCategory;
|
||||
primePartner?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -200,13 +201,14 @@ describe("ProviderPresetSelector pure helpers", () => {
|
||||
expect(getIds(filterPresetEntries(presetEntries, "聚合", t))).toEqual([]);
|
||||
});
|
||||
|
||||
it("支持 A-Z 排序、original 副本恢复原顺序,并且 getVisible 先 filter 再 sort", () => {
|
||||
it("支持 A-Z 排序、original 模式将官方分类置顶,并且 getVisible 先 filter 再 sort", () => {
|
||||
const originalMode: PresetSortMode = "original";
|
||||
const nameAscMode: PresetSortMode = "nameAsc";
|
||||
|
||||
const original = sortPresetEntries(presetEntries, originalMode, t);
|
||||
expect(original).not.toBe(presetEntries);
|
||||
expect(getIds(original)).toEqual(["gamma", "alpha", "beta", "delta"]);
|
||||
// original 模式置顶官方分类(alpha),其余保持传入顺序。
|
||||
expect(getIds(original)).toEqual(["alpha", "gamma", "beta", "delta"]);
|
||||
|
||||
expect(getIds(sortPresetEntries(presetEntries, nameAscMode, t))).toEqual([
|
||||
"alpha",
|
||||
@@ -226,16 +228,81 @@ describe("ProviderPresetSelector pure helpers", () => {
|
||||
),
|
||||
).toEqual(["alpha", "beta", "delta", "gamma"]);
|
||||
});
|
||||
|
||||
it("original 模式按「官方 → 尊享伙伴 → 其余」三段排序,各组内部保序且双重身份不重复", () => {
|
||||
// 故意打乱传入顺序,验证:
|
||||
// - official 组置顶(officialOnly、officialPrime 按出现顺序);
|
||||
// - 非官方且 primePartner 的预设居中(primeOnly);
|
||||
// - 其余保持传入顺序(restFirst、restLast);
|
||||
// - 既是 official 又是 primePartner 的预设只归入官方组、不在 prime 组重复。
|
||||
const mixed: TestPresetEntry[] = [
|
||||
{
|
||||
id: "restFirst",
|
||||
preset: {
|
||||
name: "Rest First",
|
||||
websiteUrl: "https://rest-first.example.com",
|
||||
settingsConfig: {},
|
||||
category: "third_party",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "primeOnly",
|
||||
preset: {
|
||||
name: "Prime Only",
|
||||
websiteUrl: "https://prime-only.example.com",
|
||||
settingsConfig: {},
|
||||
category: "cn_official",
|
||||
primePartner: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "officialOnly",
|
||||
preset: {
|
||||
name: "Official Only",
|
||||
websiteUrl: "https://official-only.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "officialPrime",
|
||||
preset: {
|
||||
name: "Official Prime",
|
||||
websiteUrl: "https://official-prime.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official",
|
||||
primePartner: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "restLast",
|
||||
preset: {
|
||||
name: "Rest Last",
|
||||
websiteUrl: "https://rest-last.example.com",
|
||||
settingsConfig: {},
|
||||
category: "aggregator",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(getIds(sortPresetEntries(mixed, "original", t))).toEqual([
|
||||
"officialOnly",
|
||||
"officialPrime",
|
||||
"primeOnly",
|
||||
"restFirst",
|
||||
"restLast",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderPresetSelector", () => {
|
||||
it("默认按传入的预设数组顺序渲染,不按分类或名称重新排序", () => {
|
||||
it("默认(original 模式)将官方分类置顶,其余保持传入顺序", () => {
|
||||
renderSelector();
|
||||
|
||||
expect(getPresetButtonTexts()).toEqual([
|
||||
"providerPreset.custom",
|
||||
"preset.gamma",
|
||||
"preset.alpha",
|
||||
"preset.gamma",
|
||||
"Beta Gateway",
|
||||
"Delta Mirror",
|
||||
]);
|
||||
@@ -259,8 +326,8 @@ describe("ProviderPresetSelector", () => {
|
||||
|
||||
expect(getPresetButtonTexts()).toEqual([
|
||||
"providerPreset.custom",
|
||||
"preset.gamma",
|
||||
"preset.alpha",
|
||||
"preset.gamma",
|
||||
"Beta Gateway",
|
||||
"Delta Mirror",
|
||||
]);
|
||||
@@ -421,18 +488,80 @@ describe("ProviderPresetSelector", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击搜索区域外自动收起并清空", async () => {
|
||||
it("按 Ctrl+F 快捷键打开搜索输入框", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelector();
|
||||
|
||||
// 初始没有搜索输入框
|
||||
expect(
|
||||
screen.queryByRole("textbox", {
|
||||
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// 按 Ctrl+F 展开输入框
|
||||
await user.keyboard("{Control>}f{/Control}");
|
||||
expect(getSearchInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("搜索后点击预设按钮可选中预设且不清空搜索关键词", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPresetChange = vi.fn();
|
||||
renderSelector({ onPresetChange });
|
||||
|
||||
await user.click(getSearchButton());
|
||||
await user.type(getSearchInput(), "gateway");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Beta Gateway" }));
|
||||
|
||||
expect(onPresetChange).toHaveBeenCalledWith("beta");
|
||||
// 搜索框仍展开、关键词保留
|
||||
expect(getSearchInput()).toBeInTheDocument();
|
||||
expect(getSearchInput()).toHaveValue("gateway");
|
||||
});
|
||||
|
||||
it("搜索已打开、焦点在别处时再次 Ctrl+F 把焦点移回搜索框且保留关键词", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelector();
|
||||
|
||||
await user.click(getSearchButton());
|
||||
await user.type(getSearchInput(), "gateway");
|
||||
|
||||
// 选中 preset 后焦点离开搜索框(搜索框仍展开、关键词保留)
|
||||
await user.click(screen.getByRole("button", { name: "Beta Gateway" }));
|
||||
expect(getSearchInput()).not.toHaveFocus();
|
||||
|
||||
// 再次 Ctrl+F:setSearchOpen(true) 同值不重渲染、autoFocus 不重触发,
|
||||
// 需靠快捷键命中时的命令式聚焦把焦点移回搜索框,且不清空关键词
|
||||
await user.keyboard("{Control>}f{/Control}");
|
||||
await waitFor(() => expect(getSearchInput()).toHaveFocus());
|
||||
expect(getSearchInput()).toHaveValue("gateway");
|
||||
});
|
||||
|
||||
it("点击组件外区域自动收起并清空", async () => {
|
||||
const user = userEvent.setup();
|
||||
const Wrapper = () => {
|
||||
const form = useForm();
|
||||
return (
|
||||
<Form {...form}>
|
||||
<ProviderPresetSelector
|
||||
selectedPresetId="custom"
|
||||
presetEntries={presetEntries}
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={vi.fn()}
|
||||
/>
|
||||
<div data-testid="outside">Outside</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
|
||||
await user.click(getSearchButton());
|
||||
await user.type(getSearchInput(), "gateway");
|
||||
expect(getSearchInput()).toBeInTheDocument();
|
||||
|
||||
// 点击搜索区域外的元素(custom 按钮)应收起搜索框
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "providerPreset.custom" }),
|
||||
);
|
||||
// 点击组件外的元素应收起搜索框
|
||||
await user.click(screen.getByTestId("outside"));
|
||||
|
||||
expect(
|
||||
screen.queryByRole("textbox", {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SELECT_TSX = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"components",
|
||||
"ui",
|
||||
"select.tsx",
|
||||
);
|
||||
|
||||
const stripComments = (source: string) =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
|
||||
describe("SelectItem indicator structure", () => {
|
||||
const source = stripComments(fs.readFileSync(SELECT_TSX, "utf8"));
|
||||
|
||||
it("renders an item indicator before item text", () => {
|
||||
const indicatorIndex = source.indexOf("SelectPrimitive.ItemIndicator");
|
||||
const itemTextIndex = source.indexOf("SelectPrimitive.ItemText");
|
||||
|
||||
expect(indicatorIndex).toBeGreaterThan(-1);
|
||||
expect(itemTextIndex).toBeGreaterThan(-1);
|
||||
expect(indicatorIndex).toBeLessThan(itemTextIndex);
|
||||
});
|
||||
|
||||
it("wraps the indicator in the first direct span", () => {
|
||||
const itemTextIndex = source.indexOf("SelectPrimitive.ItemText");
|
||||
const beforeItemText = source.slice(0, itemTextIndex);
|
||||
const lastSpanOpen = beforeItemText.lastIndexOf("<span");
|
||||
const lastIndicator = beforeItemText.lastIndexOf("ItemIndicator");
|
||||
|
||||
expect(lastSpanOpen).toBeGreaterThan(-1);
|
||||
expect(lastSpanOpen).toBeLessThan(lastIndicator);
|
||||
});
|
||||
});
|
||||
@@ -344,9 +344,7 @@ describe("SettingsPage Component", () => {
|
||||
fireEvent.click(screen.getByText("settings.advanced.data.title"));
|
||||
|
||||
// 有文件时,点击导入按钮执行 importConfig
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /settings\.import/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /settings\.import/ }));
|
||||
expect(importExportMock.importConfig).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(
|
||||
@@ -359,6 +357,20 @@ describe("SettingsPage Component", () => {
|
||||
expect(importExportMock.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reset tab content scroll position when switching settings tabs", () => {
|
||||
const { container } = renderSettingsPage();
|
||||
const scrollContainer = container.querySelector(
|
||||
".overflow-y-auto",
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
expect(scrollContainer).not.toBeNull();
|
||||
|
||||
scrollContainer!.scrollTop = 640;
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
|
||||
expect(scrollContainer!.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("should pass onImportSuccess callback to useImportExport hook", async () => {
|
||||
const onImportSuccess = vi.fn();
|
||||
|
||||
|
||||
@@ -5,33 +5,49 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
SkillsPage,
|
||||
getSkillsPageHeaderActions,
|
||||
type SkillsPageHandle,
|
||||
} from "@/components/skills/SkillsPage";
|
||||
import type {
|
||||
DiscoverableSkill,
|
||||
SkillRepo,
|
||||
SkillsShDiscoverableSkill,
|
||||
SkillsShSearchResult,
|
||||
} from "@/lib/api/skills";
|
||||
|
||||
const installMutateAsyncMock = vi.fn();
|
||||
let discoverableSkillsMock: DiscoverableSkill[] = [];
|
||||
let skillReposMock: SkillRepo[] = [];
|
||||
const refetchDiscoverableMock = vi.fn();
|
||||
|
||||
// Stable cache so repeated renders see referentially-equal data.
|
||||
// SkillsPage has `useEffect([skillsShResult, ...])` that calls setState — a
|
||||
// fresh object every render would loop forever.
|
||||
const searchCache = new Map<
|
||||
string,
|
||||
{ data: SkillsShSearchResult | undefined; isLoading: boolean; isFetching: boolean }
|
||||
{
|
||||
data: SkillsShSearchResult | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPlaceholderData?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const setSearchResult = (
|
||||
query: string,
|
||||
offset: number,
|
||||
result: SkillsShSearchResult | undefined,
|
||||
state: Partial<{
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPlaceholderData: boolean;
|
||||
}> = {},
|
||||
) => {
|
||||
searchCache.set(`${query}:${offset}`, {
|
||||
data: result,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
...state,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -45,10 +61,10 @@ vi.mock("sonner", () => ({
|
||||
|
||||
vi.mock("@/hooks/useSkills", () => ({
|
||||
useDiscoverableSkills: () => ({
|
||||
data: [] as DiscoverableSkill[],
|
||||
data: discoverableSkillsMock,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
refetch: refetchDiscoverableMock,
|
||||
}),
|
||||
useInstalledSkills: () => ({
|
||||
data: [],
|
||||
@@ -58,7 +74,7 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
mutateAsync: installMutateAsyncMock,
|
||||
}),
|
||||
useSkillRepos: () => ({
|
||||
data: [],
|
||||
data: skillReposMock,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useAddSkillRepo: () => ({
|
||||
@@ -88,10 +104,35 @@ const makeSkillsShSkill = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDiscoverableSkill = (
|
||||
overrides: Partial<DiscoverableSkill> = {},
|
||||
): DiscoverableSkill => ({
|
||||
key: "repo-skill:owner-a:repo-a",
|
||||
name: "Repo Skill",
|
||||
description: "Skill from a configured repository",
|
||||
directory: "repo-skill",
|
||||
readmeUrl: "https://example.com/repo-skill",
|
||||
repoOwner: "owner-a",
|
||||
repoName: "repo-a",
|
||||
repoBranch: "main",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSkillRepo = (overrides: Partial<SkillRepo> = {}): SkillRepo => ({
|
||||
owner: "owner-a",
|
||||
name: "repo-a",
|
||||
branch: "main",
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("SkillsPage - skills.sh install (regression)", () => {
|
||||
beforeEach(() => {
|
||||
installMutateAsyncMock.mockReset();
|
||||
installMutateAsyncMock.mockResolvedValue({});
|
||||
discoverableSkillsMock = [];
|
||||
skillReposMock = [];
|
||||
refetchDiscoverableMock.mockReset();
|
||||
searchCache.clear();
|
||||
});
|
||||
|
||||
@@ -156,4 +197,143 @@ describe("SkillsPage - skills.sh install (regression)", () => {
|
||||
expect(callArgs.skill.repoName).toBe("repo-b");
|
||||
expect(callArgs.skill.name).toBe("Agent Browser B");
|
||||
});
|
||||
|
||||
it("keeps skills.sh results when submitting the same query again", async () => {
|
||||
const figmaSkill = makeSkillsShSkill({
|
||||
key: "figma-use:figma:mcp-server-guide",
|
||||
name: "figma-use",
|
||||
directory: "figma-use",
|
||||
repoOwner: "figma",
|
||||
repoName: "mcp-server-guide",
|
||||
});
|
||||
|
||||
setSearchResult("figma", 0, {
|
||||
skills: [figmaSkill],
|
||||
totalCount: 1,
|
||||
query: "figma",
|
||||
});
|
||||
|
||||
render(<SkillsPage initialApp="claude" />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
const input = screen.getByPlaceholderText(
|
||||
"skills.skillssh.searchPlaceholder",
|
||||
);
|
||||
await user.type(input, "figma");
|
||||
|
||||
const searchButton = screen.getByRole("button", {
|
||||
name: "skills.search",
|
||||
});
|
||||
await user.click(searchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the skills.sh loading state while a new query is fetching", async () => {
|
||||
const figmaSkill = makeSkillsShSkill({
|
||||
key: "figma-use:figma:mcp-server-guide",
|
||||
name: "figma-use",
|
||||
directory: "figma-use",
|
||||
repoOwner: "figma",
|
||||
repoName: "mcp-server-guide",
|
||||
});
|
||||
|
||||
setSearchResult("figma", 0, {
|
||||
skills: [figmaSkill],
|
||||
totalCount: 1,
|
||||
query: "figma",
|
||||
});
|
||||
setSearchResult("react", 0, undefined, { isFetching: true });
|
||||
|
||||
render(<SkillsPage initialApp="claude" />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
const input = screen.getByPlaceholderText(
|
||||
"skills.skillssh.searchPlaceholder",
|
||||
);
|
||||
await user.type(input, "figma");
|
||||
|
||||
const searchButton = screen.getByRole("button", {
|
||||
name: "skills.search",
|
||||
});
|
||||
await user.click(searchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "react");
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText("skills.skillssh.loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the effective skills.sh source to parent chrome", async () => {
|
||||
const onSourceChange = vi.fn();
|
||||
|
||||
render(<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("skillssh");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the repository source when configured repositories return no discoverable skills", async () => {
|
||||
skillReposMock = [makeSkillRepo()];
|
||||
const onSourceChange = vi.fn();
|
||||
|
||||
render(<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("repos");
|
||||
});
|
||||
expect(
|
||||
screen.getByPlaceholderText("skills.searchPlaceholder"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("can switch back to repository results after discoverable skills refresh", async () => {
|
||||
const onSourceChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("skillssh");
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
|
||||
discoverableSkillsMock = [makeDiscoverableSkill()];
|
||||
skillReposMock = [makeSkillRepo()];
|
||||
rerender(
|
||||
<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "skills.searchSource.repos" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Repo Skill")).toBeInTheDocument();
|
||||
expect(onSourceChange).toHaveBeenCalledWith("repos");
|
||||
});
|
||||
|
||||
it("exposes repository-only header actions for the parent chrome", () => {
|
||||
expect(
|
||||
getSkillsPageHeaderActions("repos").map((action) => action.key),
|
||||
).toEqual(["refresh-repos", "manage-repos"]);
|
||||
expect(
|
||||
getSkillsPageHeaderActions("skillssh").map((action) => action.key),
|
||||
).toEqual(["manage-repos"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
|
||||
describe("Kimi For Coding Provider Preset", () => {
|
||||
const kimiForCoding = providerPresets.find(
|
||||
(p) => p.name === "Kimi For Coding",
|
||||
);
|
||||
|
||||
it("should include Kimi For Coding preset", () => {
|
||||
expect(kimiForCoding).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use template placeholder for Claude Code auto-compact window", () => {
|
||||
const env = (kimiForCoding!.settingsConfig as any).env;
|
||||
expect(env).toHaveProperty(
|
||||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
|
||||
"${CLAUDE_CODE_AUTO_COMPACT_WINDOW}",
|
||||
);
|
||||
});
|
||||
|
||||
it("should expose auto-compact window as editable template value with Kimi default", () => {
|
||||
const values = (kimiForCoding!.templateValues as any)
|
||||
?.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
||||
expect(values).toBeDefined();
|
||||
expect(values.defaultValue).toBe("262144");
|
||||
expect(values.editorValue).toBe("262144");
|
||||
expect(values.label).toBe("Auto Compact Window");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS Bedrock Provider Presets", () => {
|
||||
const bedrockAksk = providerPresets.find(
|
||||
(p) => p.name === "AWS Bedrock (AKSK)",
|
||||
|
||||
@@ -63,6 +63,48 @@ describe("Codex TOML utils", () => {
|
||||
expect(extractCodexModelName(output2)).toBe("new-model");
|
||||
});
|
||||
|
||||
it("updates a double-quoted base_url containing single quotes without duplicating it", () => {
|
||||
const input = [
|
||||
'model_provider = "custom"',
|
||||
'model = "gpt-5.4"',
|
||||
"",
|
||||
"[model_providers.custom]",
|
||||
'name = "custom"',
|
||||
"base_url = \"https://su'us.codes/v1\"",
|
||||
'wire_api = "responses"',
|
||||
'requires_openai_auth = true',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const output = setCodexBaseUrl(input, "https://su'us'd.codes/v1");
|
||||
|
||||
expect(extractCodexBaseUrl(output)).toBe("https://su'us'd.codes/v1");
|
||||
expect(output.match(/^\s*base_url\s*=/gm)).toHaveLength(1);
|
||||
expect(output).toContain("base_url = \"https://su'us'd.codes/v1\"");
|
||||
});
|
||||
|
||||
it("collapses duplicate base_url lines when editing the active provider section", () => {
|
||||
const input = [
|
||||
'model_provider = "custom"',
|
||||
'model = "gpt-5.4"',
|
||||
"",
|
||||
"[model_providers.custom]",
|
||||
'name = "custom"',
|
||||
'base_url = "https://old.example/v1"',
|
||||
'base_url = "https://older.example/v1"',
|
||||
'wire_api = "responses"',
|
||||
'requires_openai_auth = true',
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const output = setCodexBaseUrl(input, "https://new.example/v1");
|
||||
|
||||
expect(extractCodexBaseUrl(output)).toBe("https://new.example/v1");
|
||||
expect(output.match(/^\s*base_url\s*=/gm)).toHaveLength(1);
|
||||
expect(output).toContain('base_url = "https://new.example/v1"');
|
||||
expect(output).not.toContain("older.example");
|
||||
});
|
||||
|
||||
it("reads and writes base_url in the active provider section", () => {
|
||||
const input = [
|
||||
'model_provider = "custom"',
|
||||
|
||||
Reference in New Issue
Block a user