mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2cb51c59 | ||
|
|
b796b4d2cd | ||
|
|
16f64e3982 | ||
|
|
4772287fe1 | ||
|
|
163285cf32 | ||
|
|
d5f4a6572b | ||
|
|
bca9d0af55 | ||
|
|
ed5bdad63f | ||
|
|
47a13c8674 | ||
|
|
5dd375ce97 | ||
|
|
16380882a0 | ||
|
|
9c75c4295d | ||
|
|
b0585e61be | ||
|
|
f2bf8bc73f | ||
|
|
96b24f7b81 | ||
|
|
392b2b1bd5 | ||
|
|
0e28260197 | ||
|
|
edd399c079 | ||
|
|
460f629925 | ||
|
|
3970bbeb10 | ||
|
|
999b66a901 | ||
|
|
46fece2f44 | ||
|
|
71b97bf6b4 | ||
|
|
7e3551babe |
@@ -177,7 +177,7 @@ jobs:
|
||||
- name: Windows x64
|
||||
platform: windows-latest
|
||||
macos: false
|
||||
args: --bundles msi
|
||||
args: --bundles nsis
|
||||
asset_pattern: Codex-X-[version]-windows-x64[ext]
|
||||
|
||||
steps:
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
releaseAssetNamePattern: ${{ matrix.asset_pattern }}
|
||||
uploadUpdaterJson: true
|
||||
uploadUpdaterSignatures: true
|
||||
updaterJsonPreferNsis: false
|
||||
updaterJsonPreferNsis: true
|
||||
retryAttempts: 3
|
||||
|
||||
- name: Verify updater signatures were generated
|
||||
@@ -328,6 +328,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test Windows installation and legacy MSI migration
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installers = @(Get-ChildItem apps/desktop/src-tauri/target/release/bundle/nsis -Filter '*.exe')
|
||||
if ($installers.Count -ne 1) { throw 'Expected one NSIS installer.' }
|
||||
./scripts/windows-install-smoke.ps1 -Installer $installers[0].FullName
|
||||
|
||||
- name: Verify signed and notarized macOS bundles
|
||||
if: matrix.macos
|
||||
run: |
|
||||
@@ -355,11 +363,12 @@ jobs:
|
||||
rm -rf "$portable_root"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
cp THIRD_PARTY_NOTICES.md "$portable_dir/THIRD_PARTY_NOTICES.md"
|
||||
touch "$portable_dir/Codex-X.portable"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
Double click Codex-X.exe to run. No installation is required.
|
||||
The portable edition uses manual downloads instead of in-app installation.
|
||||
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
@@ -434,6 +443,7 @@ jobs:
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--rewrite-download-urls \
|
||||
--migrate-windows-to-nsis \
|
||||
--require-signature-assets
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Windows installer verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'codex/windows-updater-*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-installer-check-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
windows-installer:
|
||||
name: Verify Windows update migration
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
with:
|
||||
version: 10.26.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: apps/desktop/src-tauri
|
||||
cache-on-failure: true
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --dir apps/desktop typecheck
|
||||
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
- name: Build test installer without publishing or release signing credentials
|
||||
run: |
|
||||
Set-Content -Path "$env:RUNNER_TEMP\installer-check.json" -Value '{"bundle":{"createUpdaterArtifacts":false}}' -Encoding utf8
|
||||
pnpm --dir apps/desktop tauri build --debug --bundles nsis --config "$env:RUNNER_TEMP\installer-check.json" --ci
|
||||
- name: Test real MSI migration and subsequent updates
|
||||
run: |
|
||||
$installers = @(Get-ChildItem apps/desktop/src-tauri/target/debug/bundle/nsis -Filter '*.exe')
|
||||
if ($installers.Count -ne 1) { throw 'Expected one Windows installer.' }
|
||||
./scripts/windows-install-smoke.ps1 -Installer $installers[0].FullName
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Windows MSI API probe
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['codex/windows-updater-*']
|
||||
paths: ['scripts/windows-msi-probe.ps1', '.github/workflows/windows-msi-probe.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
msi-probe:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "${env:ProgramFiles(x86)}\NSIS\makensis.exe")) {
|
||||
choco install nsis --yes --no-progress
|
||||
}
|
||||
- shell: pwsh
|
||||
run: ./scripts/windows-msi-probe.ps1
|
||||
+238
-314
@@ -1,519 +1,443 @@
|
||||
# Changelog
|
||||
# 更新日志
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
记录 Codex-X 的新功能、体验改进和问题修复。
|
||||
|
||||
## [v0.3.23] - 2026-10-01
|
||||
|
||||
- 会话管理新增工作文件夹侧栏、文件夹与会话置顶,并使用虚拟列表减少大量会话的渲染开销。
|
||||
- 会话日志改为流式读取和磁盘快照处理,大日志及超长单条记录也能同步;读取、写入或复查失败时明确报错并执行安全回滚,避免日志、数据库索引与同步状态不一致。
|
||||
- 修复文件夹路径大小写混淆导致显示错误会话、置顶联动和缓存串用的问题,按实际文件系统身份区分目录或合并同目录别名。
|
||||
- 置顶按 Codex 配置目录分别保存,文件夹暂时不可读或重建后按原始路径保留置顶。旧 PR 预览版的 v1 置顶缓存不自动迁移,需重新置顶。
|
||||
|
||||
## [v0.3.22] - 2026-09-30
|
||||
|
||||
- 修复第三方模型被错误识别为“仅文本”而无法输入图片的问题,并自动升级旧版生成的模型目录。需供应商模型本身支持图片,更新菜单后重启 Codex 生效。
|
||||
- 供应商卡片支持上下拖动排序并自动保存,重启后保留顺序;也可聚焦左侧手柄后使用上下箭头移动。
|
||||
- 在供应商卡片显示已选模型名称,设置映射别名时同时保留真实模型 ID。
|
||||
- 补充 Ultra 思考强度的 Codex 设置提示,并修复旧模型目录遗漏图片或完整思考强度的问题。
|
||||
|
||||
## [v0.3.21] - 2026-09-23
|
||||
|
||||
- 优化 Windows 在线更新,缩短安装等待;旧版可自动迁移,正常更新无需重启电脑,账号和配置保留。
|
||||
|
||||
## [v0.3.20] - 2026-09-18
|
||||
|
||||
- 新增「设置 → 路由与故障转移」:可管理本地路由、安排供应商优先顺序,并在服务异常时自动切换;支持调整超时和重试参数,官方登录也可使用本地路由。
|
||||
- 修复添加或切换第三方供应商时,MCP、桌面设置等配置丢失的问题。
|
||||
- 修复内部任务被误同步,以及回退后的正常会话被误报异常的问题。
|
||||
|
||||
## [v0.3.18] - 2026-09-17
|
||||
|
||||
- 第三方模型映射后可在 Codex 中选择推理等级,并修复 DeepSeek 每次发送消息先报错、重试后才能使用的问题。
|
||||
- 新增后台配置检查:发现可能导致 Codex 无法使用的问题时提醒你,可选择修复,也可稍后到「设置 → 通用设置」处理。修复前会自动备份。
|
||||
- 会话支持导出为 Markdown,多选会话可打包下载;MCP 和 Skills 支持 ZIP 导出、重新导入,取消 Skills ZIP 的 20 MB 大小限制。
|
||||
- 修复「导入已有」弹窗的暗色显示、无效 MCP 重复出现,以及大日志记录被用量统计跳过的问题。
|
||||
|
||||
## [v0.3.17] - 2026-09-09
|
||||
|
||||
### 新增与优化
|
||||
|
||||
- 官方账号支持查看剩余额度、重置时间和可用重置次数,列表中显示邮箱、套餐和登录状态。
|
||||
- 新增 DeepSeek、MiniMax、小米 MiMo 等主流厂商预设和模型映射,方便在 Codex 中使用第三方模型,并保留已有配置。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复编辑正在使用的供应商时保存报错、切换后出现重复供应商,以及部分第三方连接认证失败的问题。
|
||||
- 修复用量统计切换日期范围时页面闪白的问题。
|
||||
|
||||
## [v0.3.16] - 2026-09-09
|
||||
|
||||
### 新增与调整
|
||||
### 新功能
|
||||
|
||||
- 官方登录与第三方供应商统一为一键复制:副本立即加入列表,不进入编辑页,不弹出二次确认;自动添加不重复的副本名称。
|
||||
- 官方与第三方配置的 TOML 编辑区新增“开启 1M 上下文窗口”复选框,保留自定义压缩阈值及其他配置。
|
||||
- 设置新增“通用设置 / 用量统计”标签页,并复用统一的淡入淡出动画;用量统计展示本地 Codex Token 总量、输入与输出、缓存命中率、每日趋势、模型分布和最近 10 个会话,支持时间范围、模型筛选与手动刷新。最近会话显示聊天标题,无标题时使用项目名。
|
||||
- 用量统计按主会话归并子代理及其后代的实际用量,子代理不再单独列入最近会话或增加活跃会话数;用户主动分叉的新聊天保持独立。
|
||||
- 支持添加、复制和重命名多个官方 Codex 登录配置,可与第三方 API 供应商一起管理和切换;各配置独立保存认证与完整 TOML,切换前保留当前账号最新登录状态。
|
||||
- 指令提示词页面顶部标题的强调色统一为蓝色,适配浅色与深色主题。
|
||||
- 支持保存多个官方 Codex 账号,并为每个账号起不同的名字,方便切换使用。
|
||||
- 官方账号和第三方供应商都支持一键复制,点击后直接生成副本。
|
||||
- 新增“开启 1M 上下文窗口”选项,可按需勾选使用(需要模型支持)。
|
||||
- 设置页新增“用量统计”,可以查看 Token 用量、每日趋势、模型分布和最近 10 个会话,并按日期、模型筛选。
|
||||
- AI 后台子任务的用量合并到主对话,聊天列表只显示主会话。最近会话显示聊天标题或项目名,更容易找到。
|
||||
|
||||
### 修复 Bug
|
||||
### 体验改进
|
||||
|
||||
- 修复 GitHub 模板同步期间查看、编辑或启用提示词可能拖住供应商操作的问题;模板内容优先读取本地修改、缓存和内置版本,模板详情加载不再占用其他页面的操作状态。
|
||||
- 修复首次识别到的 CC Switch 当前供应商在切换后从列表消失的问题;切换前保存完整配置与认证信息,并处理同名记录、重复配置和失败回滚。
|
||||
- 提示词页面的标题统一为蓝色,设置页的标签切换加入过渡动画。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复同步 GitHub 提示词模板时,供应商按钮暂时无法使用的问题。
|
||||
- 修复切换供应商后,原有的 CC Switch 供应商从列表消失的问题。
|
||||
|
||||
## [v0.3.15] - 2026-08-20
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复 Windows 重启 Codex 时仅隐藏到托盘、后台未退出,以及再次重启报错的问题。
|
||||
- 修复 Windows 点击“重启 Codex”后只隐藏窗口、未真正退出,以及再次重启时报错的问题。
|
||||
|
||||
## [v0.3.14] - 2026-08-20
|
||||
|
||||
### 更新
|
||||
|
||||
- MCP 和 Skills 支持添加自定义备注。
|
||||
- 供应商支持复制,同一 API 平台可保存多个模型或相同配置,复制项会添加到列表末尾。
|
||||
- 供应商列表不再显示重复的模型信息。
|
||||
- MCP 和 Skills 支持添加自定义备注,方便区分用途。
|
||||
- 供应商支持复制,同一 API 平台可以保存多个模型或多份配置,副本放在列表末尾。
|
||||
- 简化供应商列表,不再重复显示模型信息。
|
||||
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复相同供应商配置被覆盖,以及复制当前供应商后启用状态丢失的问题。
|
||||
- 修复子代理会话被误判为“待同步”的问题。
|
||||
- 修复相同供应商配置被覆盖,以及复制当前供应商后丢失启用状态的问题。
|
||||
- 修复 Codex 自动创建的子代理会话被错误标记为“待同步”的问题。
|
||||
|
||||
## [v0.3.13] - 2026-08-18
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复归档会话被错误标记为“待同步”并参与供应商同步的问题;归档会话现在会保持归档状态,不再作为同步候选。
|
||||
- 已归档的会话不再被标记为“待同步”,切换供应商时会继续保持归档状态。
|
||||
|
||||
## [v0.3.12] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.11] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.10] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.9] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.8] - 2026-08-03
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复了某些问题。
|
||||
- 修复部分使用问题。
|
||||
|
||||
## [v0.3.7] - 2026-08-02
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复了一些 bug。
|
||||
- 修复部分使用问题。
|
||||
|
||||
## [v0.3.6] - 2026-07-31
|
||||
|
||||
### 更新
|
||||
|
||||
- 供应商与 OpenAI Official 切换改为热更新;切换后新建会话即可生效,不再要求重启 Codex 客户端。
|
||||
- macOS 关闭主窗口后进入顶部栏驻留模式,Windows 隐藏任务栏窗口;托盘菜单仍可恢复窗口或明确退出应用。
|
||||
- 新增独立的项目维护日志,记录配置写入、供应商切换、会话同步和桌面生命周期的长期约束与验证依据。
|
||||
- 官方登录与第三方供应商切换后,新建会话即可使用,无需重启 Codex。
|
||||
- 关闭主窗口后,软件会留在 macOS 菜单栏或 Windows 托盘中,可从菜单恢复窗口或退出。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复官方认证被第三方供应商覆盖或清除的问题;第三方密钥仅写入当前 provider,官方 ChatGPT 登录与官方 API Key 均使用独立快照保存并可往返恢复。
|
||||
- 修复编辑活动供应商会新建副本、重复导入 cc-switch 供应商产生多条相同记录,以及旧版 cc-switch 数据库缺少 `category` 字段时无法读取官方认证的问题。
|
||||
- 修复供应商、提示词及其他配置操作并发写入时可能覆盖或破坏 `config.toml` 的问题;写入现在使用跨进程锁、原始快照、条件原子替换和失败回滚。
|
||||
- 修复会话同步显示成功但 Codex 未实际使用同步结果的问题;只处理活动 SQLite,严格校验 SQLite 指定的 rollout 路径、文件身份和线程 ID,并兼容 `codex-dev.db` 与显式 `sqlite_home` 下的自定义数据库名。
|
||||
- 修复会话列表将旧数据库或孤立 JSONL 计入活动会话、把 JSONL 记录行数误当会话数,以及同步过程中修改非活动数据库或工作目录字段的问题。
|
||||
- 修复切换第三方供应商时丢失官方登录或官方 API Key 的问题,现在切回官方时可以恢复。
|
||||
- 修复编辑当前供应商时意外新增副本、重复导入 cc-switch 产生重复项的问题,并兼容旧版 cc-switch 的官方登录导入。
|
||||
- 修复同时操作供应商、提示词或其他设置时,配置被覆盖或损坏的问题。
|
||||
- 修复会话同步提示成功却未生效的问题,并改善对自定义会话存储位置的支持。
|
||||
- 修复旧记录被重复计入会话数量,以及同步时误改旧会话或项目位置的问题。
|
||||
|
||||
## [v0.3.5] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
|
||||
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
|
||||
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
|
||||
- GitHub 提示词模板可以直接查看和编辑内容,名称和文件名仍跟随在线版本。
|
||||
- 编辑后的模板会标记为“本地已修改”,后续同步不会覆盖你的修改,新模板仍会正常下载;修改在下次启用模板时生效。
|
||||
- 仅查看模板或保存未改动的内容,不会影响后续在线更新。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
|
||||
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
|
||||
- 修复同步 GitHub 模板时只能看到 5 个、无法获取完整 11 个模板的问题;网络异常时仍可使用已有缓存。
|
||||
- 修复使用第三方供应商时,概览页误报未找到官方登录信息的问题。
|
||||
|
||||
## [v0.3.4] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
|
||||
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
|
||||
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
|
||||
- 在线模板库新增 6 套提示词,点击同步 GitHub 模板即可获取,不增加安装包体积。
|
||||
- 软件开发新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”。
|
||||
- 写作辅助新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。新模板会自动分类,仍保留用户手动设置的分类。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
|
||||
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
|
||||
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
|
||||
- 修复切换第三方供应商或从 cc-switch 导入后,官方登录信息被覆盖、需要重新登录的问题。
|
||||
- “还原官方配置”不再自动切换当前供应商;新增“新建官方配置”,方便清除异常认证后重新登录。
|
||||
- 调整官方认证编辑框,长内容可以换行显示,更方便查看和选择。
|
||||
|
||||
## [v0.3.3] - 2026-07-26
|
||||
|
||||
### 调整
|
||||
|
||||
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
|
||||
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
|
||||
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
|
||||
- 暂时下线皮肤中心,隐藏侧边栏入口,保留用户已保存的本地主题文件。
|
||||
- 升级后会自动关闭此前启用的皮肤,恢复 Codex 官方外观;若恢复失败,会显示原因。
|
||||
- 安装包不再附带“椎名真白·樱花画室”主题壁纸,减小下载体积。
|
||||
|
||||
## [v0.3.2] - 2026-07-25
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
|
||||
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
|
||||
- 修复 Windows 启动 Codex 或进入新版首页、外观设置时,皮肤功能错误提示无法识别 Codex 的问题。
|
||||
- 皮肤出现异常时仍可点击“关闭皮肤”,恢复过程中会避免重复操作。
|
||||
|
||||
### 安全
|
||||
### 安全改进
|
||||
|
||||
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
|
||||
- 皮肤功能仅用于已识别的官方 Codex 窗口,不会应用到普通网页。
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
|
||||
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
|
||||
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
|
||||
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
|
||||
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
|
||||
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
|
||||
- 重新设计皮肤中心,可直接预览壁纸、侧边栏、会话区和输入框的整体效果,主题卡片更紧凑。
|
||||
- 支持主题分类筛选,卡片会根据数量自动调整为 1 至 4 列;导出主题可使用系统“另存为”窗口。
|
||||
- 支持从图片创建主题,并修改名称、简介和界面透明度。
|
||||
- 皮肤功能支持 Windows 官方商店版 Codex,无需修改 Codex 安装文件。
|
||||
- 合并重复的恢复按钮,统一使用“关闭皮肤”。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
|
||||
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
|
||||
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
|
||||
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
|
||||
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
|
||||
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
|
||||
- 修复 macOS 应用皮肤后一直显示“应用中”,以及重开软件后无法识别当前主题的问题。
|
||||
- 修复皮肤按钮状态错误、刷新缺少反馈,以及提示词或主题较多时无法继续向下滚动的问题。
|
||||
- 优化横图壁纸的裁剪,减少人物头部被切掉的情况。
|
||||
- 修复切换第三方供应商时覆盖官方登录信息的问题。
|
||||
- 改善 Codex 安装检测,减少已安装却显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后输入框消失、聊天内容透过输入框,以及图片主题预览显示错误背景的问题。
|
||||
- 修复浅色主题下壁纸颜色发白的问题,并改善透明度调整和输入框文字的清晰度。
|
||||
|
||||
### 安全
|
||||
### 安全与来源
|
||||
|
||||
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
|
||||
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
|
||||
|
||||
### 开发
|
||||
|
||||
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
|
||||
- 加强官方 Codex 识别、主题导入和切换检查。
|
||||
- 换肤功能使用 Codex Dream Skin Studio 的 MIT 授权代码,未包含其非 MIT 授权的真人主题素材。
|
||||
|
||||
## [v0.3.0] - 2026-07-16
|
||||
|
||||
### 更新
|
||||
|
||||
- 全面升级桌面界面,统一各页面布局、按钮、弹窗和 Toast,并加入深浅色模式与页面过渡动画。
|
||||
- 新增应用内更新:macOS、Windows MSI 和 Linux AppImage 可直接下载、安装并重新启动;发现更新时会在侧边栏显示入口和进度。
|
||||
- 完善供应商管理:删除增加二次确认,连接测试改用 `/v1/models`,添加或编辑时可获取并选择模型,同时简化 OpenAI Official 页面。
|
||||
- 完善指令提示词管理:新增分类功能,内置两套新模板,精简模板卡片,添加或编辑后只保存、不自动启用。
|
||||
- 在线提示词会在启动后后台同步并保存到本机;支持 CDN、GitHub 备用源和系统代理,离线时也能使用上次同步内容。
|
||||
- 简化会话管理:统一同步到当前供应商,取消启动时自动修改,并支持查看、选择和删除内部会话。
|
||||
- 优化 Skills/MCP 导入,自动排除已经导入的内容,没有新内容时不再弹出空窗口。
|
||||
- 全面更新界面,统一页面、按钮、弹窗和通知样式,加入深浅色模式与页面切换动画。
|
||||
- 新增应用内更新,macOS、Windows MSI 和 Linux AppImage 用户可直接下载、安装并重启;侧边栏会显示更新入口和进度。
|
||||
- 改进供应商连接测试,删除前增加确认,添加或编辑时可以获取并选择模型,官方登录页面更简洁。
|
||||
- 提示词新增分类和两套内置模板,卡片更简洁;添加或编辑后只保存,需要时再手动启用。
|
||||
- 启动后会在后台同步在线模板,网络异常时尝试备用下载来源;模板会保存到本机,离线时仍能使用,支持系统代理。
|
||||
- 会话统一同步到当前供应商,不再在启动时自动修改;支持查看、选择和删除内部会话。
|
||||
- 导入 Skills / MCP 时会跳过已有内容,没有新内容时不再弹出空窗口。
|
||||
|
||||
### 修复 Bug
|
||||
### 问题修复
|
||||
|
||||
- 修复旧提示词配置可能导致 Codex 报 `expected u32`,以及 Windows `.codex` 使用链接目录后无法正常读写的问题。
|
||||
- 修复会话同步可能改写聊天内容、出现 `invalid_id_prefix`、重复显示会话或覆盖正在写入记录的问题。
|
||||
- 修复深浅色模式下的文字、滚动条、复选框和弹窗显示异常,以及弹窗打开后背景仍能滚动的问题。
|
||||
- 修复提示词缓存延迟造成的重复同步、错误更新或误删除,并修复应用内更新失败后的错误状态提示。
|
||||
- 修复部分旧提示词配置导致 Codex 报错,以及 Windows 将配置文件夹链接到其他位置后无法读写的问题。
|
||||
- 修复会话同步报错、会话重复显示,以及聊天记录被误改或覆盖的问题。
|
||||
- 修复深浅色模式下部分文字和控件显示异常,以及打开弹窗后背景仍能滚动的问题。
|
||||
- 修复提示词重复同步、错误更新或误删除,以及应用更新失败后提示不准确的问题。
|
||||
- 完善安装包和更新的安全检查。
|
||||
|
||||
### 兼容说明
|
||||
|
||||
- `v0.3.0` 是首个支持应用内更新的版本,旧版用户需要手动安装本版本一次。
|
||||
- Windows 便携版和 Linux deb/rpm 继续手动更新;Windows MSI 首次安装仍可能出现 SmartScreen 提示。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加更新包签名、macOS 签名与公证,并完成后端模块拆分和发布流程安全加固。
|
||||
- Windows 便携版和 Linux deb/rpm 仍需手动更新;Windows MSI 首次安装可能出现系统安全提示(SmartScreen)。
|
||||
|
||||
## [v0.2.35] - 2026-07-13
|
||||
|
||||
### 更新
|
||||
|
||||
- 新增 `gpt-5.6-sol-unrestricted.md` 指令提示词模板,同步 GitHub 模板后即可在【指令提示词】页面启用。
|
||||
- 优化第三方供应商切换:切换后新建或重新打开 Codex 会话即可使用新的中转,无需重启整个 Codex 客户端。
|
||||
- 优化从 cc-switch 导入供应商的结果提示,现在会分别显示新增、更新、合并和跳过数量。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复相同 API 地址和 API Key 的供应商因名称、模型或其他配置不同而重复显示的问题;相同地址但不同 Key 的供应商仍会分别保留,升级后也会自动整理已有重复项。
|
||||
- 修复切换到其他供应商或切回 OpenAI Official 后,原供应商可能从列表中消失的问题;切换前会自动保存当前供应商。
|
||||
- 修复从 cc-switch 重复导入时可能覆盖 Codex-X 中已有供应商名称、模型或自定义配置的问题,并兼容旧版 cc-switch 数据。
|
||||
- 优化会话列表:Codex 自动创建的后台子会话不再重复显示为普通会话,项目数量和会话总数更加准确。
|
||||
- 优化会话编号显示,保留编号开头和结尾,减少多个会话看起来像同一个会话的情况。
|
||||
- 修复检测到的供应商与本地供应商同名时,编辑保存可能因 ID 冲突覆盖另一条记录的问题。
|
||||
- 新增 `gpt-5.6-sol-unrestricted.md` 提示词模板,同步 GitHub 模板后即可启用。
|
||||
- 切换第三方供应商后,新建或重新打开 Codex 会话即可使用新供应商,无需重启整个 Codex 客户端。
|
||||
- 自动整理 API 地址和密钥均相同的重复供应商;同一地址使用不同密钥的供应商仍分别保留。
|
||||
- 修复切换供应商或切回官方登录后,原供应商可能消失的问题。
|
||||
- 从 cc-switch 导入时,会显示新增、更新、合并和跳过数量;重复导入不再覆盖已有名称、模型和自定义配置,并兼容旧版 cc-switch 数据。
|
||||
- 会话列表不再重复展示 Codex 自动创建的后台子会话,项目和会话数量更准确;会话编号保留首尾,方便区分。
|
||||
- 修复编辑同名供应商时可能覆盖另一条记录的问题。
|
||||
|
||||
## [v0.2.34] - 2026-07-13
|
||||
|
||||
### 更新
|
||||
|
||||
- 会话管理新增指定会话永久删除:支持单选、多选,并在二次确认后从当前 Codex 存储中硬删除;操作不进入回收站、不创建新的删除备份,Codex-X 内无法撤销。
|
||||
- “按项目路径分组”新增项目级三态勾选,可一次选中某个项目在当前列表中加载的全部会话,也支持跨多个项目批量选择;搜索状态下会同时显示当前命中数与项目会话总数。
|
||||
- 重做会话列表为更克制的表格式布局,统一标题、更新时间、Provider、模型和 ID 列,降低高亮边框、彩色标签与重复装饰造成的视觉干扰。
|
||||
- 移除“已选 / 需修复、选择需同步、清空选择、修复选中”等按条修复控件,保留顶部“检查会话”和“同步 / 修复”用于全量处理。
|
||||
- 永久删除确认窗口会预览待删除会话,并要求确认已关闭正在使用相关会话的其他 Codex 窗口或 CLI;由所选会话派生的子会话也会一并处理。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复默认窗口大小下会话列表滚动条在下拉过程中消失、必须放大窗口才能继续查看的问题;列表现在使用稳定的内部滚动区域并阻止滚动穿透。
|
||||
- 修复同时存在当前 `state_*.sqlite` 与旧版 `sqlite/state_*.sqlite` 时,会话总数重复统计并混入旧会话的问题;现在优先使用当前活动会话数据库。
|
||||
- 修复批量删除部分失败时结果反馈不完整的问题,界面会保留未删除项并显示失败数量与原因,方便关闭占用进程后重试。
|
||||
|
||||
### 开发
|
||||
|
||||
- 永久删除优先调用 Codex App Server 官方 `thread/delete`,不可用时使用本地兼容清理,并复用跨平台 Codex 可执行文件探测与启动逻辑。
|
||||
- 本地兼容清理覆盖父子会话、当前及旧版会话数据库关联记录、活动/归档 Rollout、会话索引、历史记录、Shell 快照以及日志、记忆、目标和本地目录等关联数据。
|
||||
- 会话同步与删除共用维护锁,并增加 Rollout 规范路径校验与符号链接边界保护,避免并发维护或越界删除。
|
||||
- 增加活动数据库优先级、父子会话与重复库清理、关联记录清理、Rollout 越界拒绝及符号链接防护测试。
|
||||
- 会话管理支持单选或多选后永久删除,所选会话派生的子会话也会一并删除。删除不进入回收站、不创建新的备份,在 Codex-X 中无法撤销。
|
||||
- 删除前可预览待删除会话,并需确认已关闭其他正在使用这些会话的 Codex 窗口或命令行进程。
|
||||
- 支持按项目勾选当前已加载的会话,也可跨项目批量选择;搜索时同时显示匹配数量和项目会话总数。
|
||||
- 会话列表改为紧凑表格,标题、更新时间、供应商、模型和编号更清晰。检查与修复统一使用顶部的“检查会话”和“同步 / 修复”。
|
||||
- 修复默认窗口大小下会话列表无法继续向下滚动的问题。
|
||||
- 修复升级 Codex 后可能重复统计会话、混入旧会话的问题。
|
||||
- 批量删除部分失败时,会保留未删除项并显示失败数量和原因,方便处理后重试。
|
||||
|
||||
## [v0.2.33] - 2026-07-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 重做【指令提示词】页面布局:当前状态、启用方式和模板列表使用统一的紧凑框架,模板来源与更新时间更容易查看。
|
||||
- GitHub 提示词目录改为权威同步:自动发现并缓存新增 `.md`,清理远端已删除模板和历史别名缓存;离线时仍可使用上次成功同步的内容。
|
||||
- GitHub `examples/` 新增 `海鸥3.0破甲.md`,并清理不再使用的旧示例模板。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复首次进入提示词页或切换导航返回时,会先显示历史缓存、随后再跳到 GitHub 最新模板的问题;同一次启动中会保留已同步状态,不再重复回退旧列表。
|
||||
- 修复提示词模板较多时列表溢出最外层边框的问题,外层面板现在会随完整列表正确增高。
|
||||
- 修复 GitHub 同步并发、部分模板下载失败和异常目录响应可能造成缓存状态错乱或误删的问题;失败后允许重新同步。
|
||||
- 修复远端模板已删除但仍在启用时,重启后可能无法识别和关闭的问题;当前生效模板会保留明确的关闭入口。
|
||||
- 删除 `gpt5.5-jeli.md` 介绍中多余的“测试生效”文案。
|
||||
- 修复 macOS Apple Silicon / Intel、Windows 和 Linux 应用图标外围出现大块白边的问题,重新生成全平台透明图标资源。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 GitHub 模板动态发现、缓存去重、离线回退、过期缓存删除和异常目录保护测试,并为缓存刷新增加互斥与事务保护。
|
||||
- 修正发布脚本遗漏 `Cargo.lock` 的问题,确保 tag 内 Rust 包版本与应用版本一致。
|
||||
- 重新整理【指令提示词】页面,当前状态、启用方式、模板来源和更新时间更清晰,并简化模板说明。
|
||||
- 同步 GitHub 时会自动添加新模板、移除已删除模板;离线时仍可使用上次成功同步的内容。
|
||||
- 新增 `海鸥3.0破甲.md` 示例模板,清理不再使用的旧示例。
|
||||
- 修复返回提示词页面时列表先显示旧内容再跳到新内容,以及模板较多时列表超出边框的问题。
|
||||
- 修复同步或下载失败时模板列表可能出错、误删的问题,失败后可重新同步;正在使用的模板即使已从 GitHub 删除,重启后仍可正常关闭。
|
||||
- 修复 macOS、Windows 和 Linux 应用图标外围出现大块白边的问题。
|
||||
|
||||
## [v0.2.32] - 2026-07-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提示词页的“启用方式”新增问号说明,用户可以直接看到“保留原提示词 / 替换原提示词”对现有系统提示词和 `model_instructions_file` 的实际影响。
|
||||
- 提示词注入页面继续沿用统一的当前状态展示,方便判断当前模板与启用方式是否一致。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复提示词启用方式说明不够直观的问题,减少误操作风险。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复相同 Base URL、API Key、模型但 TOML 内容不同的供应商可能同时显示“当前”的问题;现在优先按完整 live TOML 匹配唯一配置,并避免重复展示 detected custom 卡片。
|
||||
- 修复追加提示词后禁用会误影响用户原有规则的风险;禁用只删除 Codex-X 管理的 AGENTS 区块或指令文件,用户其他内容保持不变。
|
||||
- 修复并发操作在同一毫秒创建备份时可能发生临时文件名冲突的问题。
|
||||
- 修复切换 Skill / MCP 开关后条目因启用状态排序而跳到列表其他位置的问题。
|
||||
- 修复 macOS Overlay 标题栏无法拖动的问题:补齐 Tauri 2 的 `core:window:allow-start-dragging` capability,并统一顶部拖动区域样式。
|
||||
- 修复 Windows 桌面应用环境中因 PATH 不完整或 `codex.cmd` 无法直接执行而检测不到 Codex CLI 版本的问题;新增 npm、ChatGPT、Cursor、VS Code 和常见安装目录探测。
|
||||
|
||||
### 开发
|
||||
|
||||
- 将 `AGENTS.md` 纳入 Codex-X 备份与恢复流程。
|
||||
- 增加受管区块合并/卸载、GitHub 动态模板发现、完整 TOML 供应商匹配、追加/替换模式和 AGENTS 恢复测试。
|
||||
- 提示词“启用方式”增加说明,方便了解“保留原提示词”和“替换原提示词”的区别,并核对当前启用状态。
|
||||
- 修复配置相近的多个供应商可能同时显示“当前启用”、重复显示供应商卡片的问题。
|
||||
- 关闭追加的提示词时会保留用户原有规则,自定义规则也支持备份和恢复。
|
||||
- 修复多项操作同时备份时可能失败的问题。
|
||||
- 修复切换 Skill 或 MCP 开关后,条目跳到列表其他位置的问题。
|
||||
- 修复 macOS 窗口顶部无法拖动的问题,并改善 Windows 上 Codex 版本的识别。
|
||||
|
||||
## [v0.2.31] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
- 调整 macOS Toast 展示位置:Toast 现在直接挂在窗口根层,不再被内容区域下移影响,提示位置更靠上、更自然。
|
||||
- 优化 Skills ZIP 安装体验:从 ZIP 安装 Skill 时会读取 `SKILL.md` 里的真实 `name` / `description`,不再显示 `skill-zip-时间戳` 这类临时目录名。
|
||||
- Skills 页面刷新时会自动识别旧版 `skill-zip-*` 目录,并尝试重命名为 Skill 自身名称。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复启用第三方供应商后,关闭并重新打开 Codex-X 不再显示“当前启用”的问题。
|
||||
- 修复 Windows / Linux 上供应商编辑页无法正常下拉滚动的问题,编辑 `config.toml` 时不再需要用 PageDown 绕过。
|
||||
- 修复 Toast 受内容容器 `contain` 影响导致 macOS 位置调整不生效的问题。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 Skill 元数据读取与旧 ZIP Skill 目录自动修正的单元测试。
|
||||
- 调整 macOS 顶部通知的位置,避免提示随页面内容下移。
|
||||
- 从 ZIP 安装 Skill 后显示其实际名称和说明;刷新时会尝试修正旧版安装留下的临时名称。
|
||||
- 修复启用第三方供应商后,重新打开 Codex-X 不再显示“当前启用”的问题。
|
||||
- 修复 Windows 和 Linux 供应商编辑页无法正常向下滚动的问题。
|
||||
|
||||
## [v0.2.30] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化会话管理页面布局:同步状态、目标 Provider、聊天总数、已展示数量、需修复数量合并为紧凑信息栏,把更多空间留给会话列表。
|
||||
- 会话管理新增“启动自动修复”开关:开启后,Codex-X 启动时会在后台检查本地会话完整性,发现未同步会自动修复,不阻塞界面。
|
||||
- 优化按钮点击体验:移除容易造成 WebView 闪烁/掉帧的水波纹、位移和 filter 效果,点击反馈更轻、更稳。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复同名供应商可能同时显示为“当前启用”的问题;Codex-X 现在会记录最后启用的供应商 ID,避免第三方 live Provider 都是 `custom` 时出现双亮。
|
||||
- 修复新增同名供应商时 ID 冲突的问题;新建供应商会自动生成唯一 ID。
|
||||
- 修复提示词管理中同名/同文件名 md 可能同时显示为启用的问题;新增或导入提示词会自动生成唯一文件名,当前状态按实际启用文件精确匹配。
|
||||
- 修复会话管理中会话选择反馈不明显的问题;已选数量和可修复数量会实时展示。
|
||||
|
||||
### 体验
|
||||
|
||||
- 进一步降低供应商、提示词、TOML、会话管理等页面滚动时的闪烁和低帧率感。
|
||||
|
||||
- 精简会话管理顶部信息栏,为会话列表留出更多空间;选择会话时实时显示已选数量和可修复数量。
|
||||
- 新增“启动自动修复”开关,开启后会在启动时后台检查会话,并自动修复未同步的会话,不影响界面操作。
|
||||
- 减少按钮点击和页面滚动时的闪烁、卡顿。
|
||||
- 修复同名供应商可能同时显示“当前启用”,以及新增同名供应商发生冲突的问题。
|
||||
- 修复同名提示词可能同时显示为启用的问题。
|
||||
|
||||
## [v0.2.29] - 2026-07-07
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化 Toast 提示样式:改为窗口顶部居中的产品化通知卡片,减少遮挡右上角操作按钮。
|
||||
- Provider 连接测试文案改为简洁结果:成功显示“连接正常(耗时)”,失败显示 HTTP 状态与耗时。
|
||||
- 第三方供应商编辑页支持保存 TOML 模板;点击“保存”只保存配置,点击“启用”时才写入 Codex live config。
|
||||
- 技能和 MCP 页面“导入已有”改为先展示预览确认弹窗,列出将导入的 Skills / MCP,用户确认后才执行导入。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Codex 第三方 API auth 写入错误:`auth.json` 现在写入 Codex 实际识别的 `auth_mode = "apikey"`,避免桌面端回到登录页。
|
||||
- 修复官方认证编辑页打开时会静默加载 cc-switch 旧 token,导致新登录账号 token 显示不更新的问题;现在默认读取 live `~/.codex/auth.json`,并提供“刷新当前 auth.json”。
|
||||
- 修复切换到 OpenAI Official 时可能覆盖当前 live auth.json 的问题,避免把旧账号登录态写回。
|
||||
- 修复 Provider 测试把 HTTP 403 误判为“连接正常”的问题;现在只有 2xx 状态会显示成功。
|
||||
- 修复从 cc-switch 导入 Provider 时同名同 URL 供应商可能产生重复卡片的问题。
|
||||
- 修复 SQLite 轻量迁移重复执行时 `duplicate column name: toml_config` 导致启动报错的问题。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 Provider/auth 相关单元测试,覆盖第三方切换、官方 auth 保留、403 判定和 cc-switch 导入串台场景。
|
||||
|
||||
- 通知改为顶部居中显示,减少对右上角按钮的遮挡;供应商连接测试结果更简洁,显示连接状态和耗时。
|
||||
- 第三方供应商支持保存配置模板:“保存”只保存配置,点击“启用”后才应用到 Codex。
|
||||
- Skills 和 MCP 的“导入已有”增加预览,确认列表后才执行导入。
|
||||
- 修复切换第三方供应商后 Codex 可能回到登录页的问题。
|
||||
- 官方认证编辑页默认读取当前登录信息,并提供“刷新当前 auth.json”;修复切回官方登录时可能恢复成旧账号的问题。
|
||||
- 修复连接测试将“访问被拒绝”误报为连接正常,以及从 cc-switch 导入时出现重复供应商卡片的问题。
|
||||
- 修复部分用户升级后无法启动的问题。
|
||||
|
||||
## [v0.2.28] - 2026-07-07
|
||||
|
||||
- 修复从 cc-switch 导入 Codex Provider 时可能把供应商名称、base_url 与 API Key 串台的问题。
|
||||
- 导入 cc-switch Provider 时改为先扫描所有 `[model_providers.<id>]` section,再按 provider row id 精确匹配,避免使用过期的 `model_provider` active 值。
|
||||
- 兼容 cc-switch legacy `custom` provider 模板,同时保留 `experimental_bearer_token` 兜底。
|
||||
- 增加复现 Sky2api / MagicAI 交叉配置的单元测试,防止 Provider 切换再次出现“看起来切了但实际没切对”的问题。
|
||||
- 修复从 cc-switch 导入供应商时,名称、API 地址和密钥可能相互混淆的问题,避免启用后实际使用了其他供应商。
|
||||
- 改善旧版 cc-switch 供应商配置的导入兼容性。
|
||||
|
||||
## [v0.2.27] - 2026-07-07
|
||||
|
||||
- 优化【指令提示词】页面为 Skills/MCP 风格的简洁列表:左侧显示模板名称与说明,右侧使用开启/关闭切换;自定义/导入的 md 提示词增加编辑与删除图标。
|
||||
- 优化【供应商】页面操作区:移除“官方配置 / 本地保存 / gpt-5.5 / 不支持路由”等冗余信息,将切换改为“启用”,编辑/删除/测试连接改为图标按钮。
|
||||
- 新增供应商 base_url 连通性测试按钮,方便切换前检查第三方 API 地址是否可达。
|
||||
- 修复第三方 Provider 切换后可能未真正生效的问题:切换和保存 TOML 时会写入 Codex 实际读取的 `experimental_bearer_token`。
|
||||
- 对齐 cc-switch 的 Codex Provider live config 思路:从 cc-switch 导入时会识别 `experimental_bearer_token`,并避免使用 Codex 内置保留 provider id。
|
||||
- 优化启动加载链路:启动诊断、备份列表、会话同步状态改为后台刷新,减少首屏等待。
|
||||
- 提示词页面改为简洁列表,名称和说明更清晰,右侧可直接开关;自定义和导入的提示词支持编辑、删除。
|
||||
- 精简供应商操作区,将“切换”改为“启用”,编辑、删除和连接测试改为图标按钮。
|
||||
- 新增供应商连接测试,方便启用前检查第三方 API 地址是否可用。
|
||||
- 修复第三方供应商切换后可能未真正生效的问题,并改善从 cc-switch 导入配置的兼容性。
|
||||
- 减少启动等待,检查、备份列表和会话状态在后台加载。
|
||||
|
||||
## [v0.2.26] - 2026-07-07
|
||||
|
||||
- 修复第三方 Provider 切换后可能“看起来已切换但实际未按新供应商生效”的问题:不再把所有第三方都写成 `model_provider = "custom"`,而是写入供应商自己的稳定 ID。
|
||||
- 修复从官方 ChatGPT 登录态切到第三方 API Key 时 `auth.json` 仍保留 `auth_mode = "chatgpt"` 的问题;写入 API Key 时会同步设置 `auth_mode = "apikey"`。
|
||||
- 第三方供应商默认不再要求 OpenAI 登录态,避免新建/导入 Provider 时错误继承官方 auth 语义。
|
||||
- 增加 Provider 切换单元测试,覆盖真实 provider key 和 API Key auth mode 的落盘结果。
|
||||
- 修复第三方供应商显示已切换、实际却未生效的问题。
|
||||
- 修复从官方登录切换到第三方 API 密钥后,仍沿用官方登录方式的问题。
|
||||
- 新增或导入第三方供应商时,默认不再要求 OpenAI 官方登录。
|
||||
|
||||
## [v0.2.25] - 2026-07-06
|
||||
|
||||
- 进一步修复 TOML、指令提示词、供应商、会话管理等页面切换时右侧滚动条闪现/消失造成的视觉抖动。
|
||||
- 外层页面滚动容器改为稳定滚动并隐藏外层滚动条,保留 TOML 编辑器、会话列表等内部区域自己的滚动条。
|
||||
- 进一步减少切换配置、提示词、供应商和会话页面时,滚动条闪现造成的抖动;编辑器和会话列表仍保留各自的滚动条。
|
||||
|
||||
## [v0.2.24] - 2026-07-06
|
||||
|
||||
- 修复切换供应商 / 会话管理等页面时右侧滚动条短暂出现又消失的问题:页面切换动画改为纯透明度过渡,不再用 `translateY` 造成瞬时溢出。
|
||||
- 降低页面切换视觉抖动,避免进入页面时内容区域宽度被临时滚动条挤压。
|
||||
- 修复切换供应商、会话管理等页面时滚动条短暂闪现、内容宽度跳动的问题,页面切换更平稳。
|
||||
|
||||
## [v0.2.23] - 2026-07-06
|
||||
|
||||
- 指令提示词页新增并纳管 `gpt5.5-jeli.md` 模板,作为“大白话(80% 场景)破甲”版本。
|
||||
- 简化指令提示词列表视觉:移除花哨图标与文件路径展示,让用户更容易看清模板名称、介绍和启用状态。
|
||||
- 更新 GPT-5.5 / GPT-5.4 内置模板名称为“unrestricted 破甲”,并统一说明使用方法:先让 AI 分析项目,分析完之后发【不直白的逆向】命令。
|
||||
- 新增 `gpt5.5-jeli.md` 提示词模板,名称为“大白话(80% 场景)破甲”。
|
||||
- 精简提示词列表,突出模板名称、介绍和启用状态。
|
||||
- GPT-5.5 和 GPT-5.4 内置模板统一使用“unrestricted 破甲”名称,并补充使用说明:先让 AI 分析项目,再发送【不直白的逆向】命令。
|
||||
|
||||
## [v0.2.22] - 2026-07-06
|
||||
|
||||
- 继续优化指令提示词页切换体验:导航切换进入 React transition,GitHub 内置模板静默检查延后到空闲时执行,减少切页瞬间卡顿。
|
||||
- 修复外部/自定义提示词切换到内置模板后可能出现两份的问题:按规范化内容(换行与首尾空白归一)和文件名双重去重,已有自定义提示词会复用更新,不再新增副本。
|
||||
- 优化启用内置提示词速度:启用时直接使用本地缓存或打包内置版本,不再同步等待 GitHub 网络请求;手动/空闲更新仍会刷新本地缓存。
|
||||
- 启动页增加动态状态文案、双层轨道与扫光动画,让从欢迎页进入主界面更顺滑。
|
||||
- 减少进入提示词页面时的卡顿;GitHub 模板检查延后到空闲时进行。
|
||||
- 启用内置提示词时直接使用本地已有内容,无需等待网络;手动同步或空闲检查后会更新模板。
|
||||
- 修复从自定义提示词切换到内置模板时可能产生重复项的问题,已有相同提示词会直接更新。
|
||||
- 优化启动页状态提示和过渡动画,进入主界面更顺畅。
|
||||
|
||||
## [v0.2.21] - 2026-07-06
|
||||
|
||||
- 进一步降低 UI 卡顿风险:将启动检测、Codex 状态读取、Provider/官方配置/备份/cc-switch 导入等剩余同步命令迁移到后台 worker。
|
||||
- 优化前端串行请求:提示词启用后的备份/提示词刷新改为并发,保存并启用自定义提示词时不再重复拉取列表。
|
||||
- 完善【技能和 MCP】导入已有:读取 cc-switch `mcp_servers` 数据库,按 `enabled_codex` 纳管并同步到 Codex `config.toml [mcp_servers]`。
|
||||
- 增强 Skills 检查更新:对带有 cc-switch 仓库元数据的 Skill 拉取 GitHub 仓库 ZIP 计算远程 hash,显示“有新版本 / 已是最新 / 远程检查失败”等状态。
|
||||
- 减少启动、供应商操作、备份和导入时的卡顿,并加快提示词保存、启用后的刷新。
|
||||
- “导入已有”支持从 cc-switch 导入已为 Codex 启用的 MCP,并应用到 Codex。
|
||||
- 来自 cc-switch 且保留仓库信息的 Skill 可检查 GitHub 更新,显示“有新版本”“已是最新”或“远程检查失败”。
|
||||
|
||||
## [v0.2.20] - 2026-07-06
|
||||
|
||||
- 继续优化指令提示词页性能:提示词列表/状态读取、保存、导入、启用、禁用等后端操作改为后台 worker,避免切页或启用模板时阻塞 UI。
|
||||
- 修复外部自定义提示词重复保存问题:切换到内置模板前会按文件名与内容双重去重,并自动清理历史 `external-*` 重复项。
|
||||
- 优化启动加载体验:启动页增加最短展示、退出淡出与动态过渡,避免从欢迎页突然跳到主页。
|
||||
- 改善提示词页面响应速度,减少切换页面、保存、导入和开关模板时的卡顿。
|
||||
- 修复从自定义提示词切换到内置模板后重复保存的问题,并自动整理已有的此类重复项。
|
||||
- 优化启动页展示和退出动画,避免突然跳到主页。
|
||||
|
||||
## [v0.2.19] - 2026-07-06
|
||||
|
||||
- 新增【技能和 MCP】页面:展示 Codex 当前已安装 Skills 与 MCP,支持导入已有、从 ZIP 安装 Skill、启用/禁用 Skill、启用/禁用 MCP。
|
||||
- MCP 管理会读写 `~/.codex/config.toml` 的 `[mcp_servers]`,禁用后保留到 Codex-X SQLite,后续可一键重新启用。
|
||||
- Skills 管理会扫描 `~/.codex/skills`,并可从 `~/.agents/skills`、`~/.cc-switch/skills` 导入到 Codex;禁用后移动到 Codex-X 禁用目录,避免直接删除。
|
||||
- 优化多个潜在卡顿点:指令提示词 GitHub 检查改为延迟后台执行,远程拉取、会话扫描/同步、Skills/MCP 扫描均放入后台 blocking worker。
|
||||
- 修复外部自定义提示词切换到内置提示词后重复出现的问题,并自动清理同名 `external-*` 重复项。
|
||||
- 增加页面切换过渡、启动页动态光效和首次启动向导退出动画,降低页面突然跳转感。
|
||||
- 新增【技能和 MCP】页面,可查看已安装内容、导入已有配置、从 ZIP 安装 Skill,以及启用或禁用 Skill 和 MCP。
|
||||
- 禁用 Skill 或 MCP 后仍保留原有内容,可随时重新启用;支持导入本机已有的 Skill 和 cc-switch 中的技能。
|
||||
- 减少 GitHub 模板检查、会话检查与同步、Skills 和 MCP 加载时的卡顿。
|
||||
- 修复从自定义提示词切换到内置模板后出现重复项的问题,并自动整理已有的同名重复项。
|
||||
- 优化页面切换、启动页和首次启动向导的过渡动画。
|
||||
|
||||
## [v0.2.18] - 2026-07-05
|
||||
|
||||
- 指令提示词内置模板支持从 GitHub `examples/` 实时检查更新,并缓存到本地;启用内置模板时优先使用 GitHub 最新版本,离线时自动回退本地缓存或打包内置版本。
|
||||
- 指令提示词页面新增“更新内置模板”状态与来源展示,可看到模板来自 GitHub 最新、本地缓存或打包内置。
|
||||
- 继续保留导入 `.md` 提示词、外部提示词自动记忆、会话管理交互优化和 API Key 可见切换等体验改进。
|
||||
- 内置提示词支持从 GitHub 检查更新,启用时优先使用最新模板;离线时可使用本地缓存或随应用提供的模板。
|
||||
- 提示词页面新增更新状态和模板来源提示。
|
||||
|
||||
## [v0.2.17] - 2026-07-04
|
||||
|
||||
- 修复 macOS Intel Release 构建 runner:从已不可用/长时间排队的 `macos-13` 切换为 `macos-15-intel`。
|
||||
- 继续保留 macOS Apple Silicon / macOS Intel / Windows MSI / Windows portable ZIP / Linux deb/rpm 多平台产物。
|
||||
- 修复 Intel Mac 安装包发布失败或长时间等待的问题。
|
||||
|
||||
## [v0.2.16] - 2026-07-04
|
||||
|
||||
- Release 新增 macOS Intel 构建,Intel Mac 用户可下载 x64 DMG。
|
||||
- macOS Release 现在同时提供 Apple Silicon 与 Intel 两种 DMG。
|
||||
- Windows Release 新增 portable ZIP,包含可直接运行的 `Codex-X.exe`,无需 MSI 安装。
|
||||
- 发布流程支持上传 `.zip` portable 产物,并更新 README 下载说明。
|
||||
- 新增 Intel Mac 版 DMG,macOS 用户可按芯片类型选择 Intel 或 Apple Silicon 安装包。
|
||||
- 新增 Windows 免安装 ZIP,解压后即可运行,无需安装 MSI。
|
||||
|
||||
## [v0.2.15] - 2026-07-04
|
||||
|
||||
- 会话管理页移除 CODEX_HOME 与 Provider Sync 备份位置展示,页面信息更简洁。
|
||||
- 优化 Windows 打开下载页体验:后端改为后台线程 spawn 浏览器,不再等待 `cmd /C start`,避免 WebView 卡顿 2-3 秒。
|
||||
- 统一所有外部链接按钮走异步打开逻辑,打开项目主页、反馈页、下载页都不会阻塞界面。
|
||||
- 修复 macOS 顶部 toast 被 Overlay 标题栏遮挡的问题,toast 自动下移到标题栏安全区下方。
|
||||
- 精简会话管理页面,移除不常用的配置和备份位置说明。
|
||||
- 修复 Windows 打开下载页、项目主页等外部链接时界面短暂卡顿的问题。
|
||||
- 修复 macOS 顶部提示消息被标题栏遮挡的问题。
|
||||
|
||||
## [v0.2.14] - 2026-07-04
|
||||
|
||||
- 修复 macOS Overlay 标题栏遮挡内容的问题,为红黄绿窗口按钮预留完整安全区。
|
||||
- 调整侧边栏、内容区、Provider/TOML/指令提示词/会话管理页高度计算,避免顶部内容被标题栏压住。
|
||||
- 加高 macOS 顶部拖拽区域并保持深色渐变,窗口顶部继续和应用色系统一。
|
||||
- 修复 macOS 标题栏和窗口按钮遮挡页面内容的问题。
|
||||
- 增大窗口顶部拖拽区域,并统一深色外观。
|
||||
|
||||
## [v0.2.13] - 2026-07-04
|
||||
|
||||
- macOS 窗口标题栏改为深色融合样式,避免顶部出现系统白色标题条。
|
||||
- 会话管理页面移除多余外层玻璃边框,统一为完整内容容器。
|
||||
- 优化会话列表边界与滚动区域,避免列表像卡在外层框外面。
|
||||
- macOS 标题栏改为与应用一致的深色样式。
|
||||
- 简化会话管理页面边框,改善列表布局和滚动体验。
|
||||
|
||||
## [v0.2.12] - 2026-07-04
|
||||
|
||||
- Windows MSI 安装器回退为默认简洁样式,移除上一版过重的自定义安装界面图。
|
||||
- 【会话管理】页面新增会话列表,展示标题、Provider、模型、工作目录、更新时间、归档/需同步状态。
|
||||
- 会话扫描会从 Codex 本地 SQLite threads 表读取最近会话,方便用户直接判断哪些历史 thread 需要同步修复。
|
||||
- Windows MSI 恢复简洁的安装界面。
|
||||
- 会话管理新增历史会话列表,可查看标题、供应商、模型、工作目录、更新时间,以及归档和待同步状态。
|
||||
|
||||
## [v0.2.11] - 2026-07-04
|
||||
|
||||
- 调整首次启动自动检查更新体验:不再弹出居中的强提醒窗口。
|
||||
- 自动检测到新版本时仅显示轻量 toast,并保留概览页顶部“发现新版本”提示条。
|
||||
- 只有用户在【关于】页面主动点击“检查更新”时,检测到新版本才弹出“现在下载 / 稍后”窗口。
|
||||
- 启动时发现新版本改为轻量提示,概览页仍保留更新提示条。
|
||||
- 仅在“关于”页面主动检查更新并发现新版本时,弹出“现在下载 / 稍后”窗口。
|
||||
|
||||
## [v0.2.10] - 2026-07-04
|
||||
|
||||
- 修复 Release 发布任务上传 Linux 产物时误把 `deb/`、`rpm/` 目录当作资产上传的问题。
|
||||
- 发布任务现在只收集 `.dmg` / `.msi` / `.deb` / `.rpm` 文件并统一上传。
|
||||
- 补齐稳定的三平台 Release 自动发布流程。
|
||||
- 修复 Linux 安装包上传失败的问题,完善 macOS、Windows 和 Linux 安装包发布。
|
||||
|
||||
## [v0.2.9] - 2026-07-04
|
||||
|
||||
- 修复 GitHub Actions Release 发布流程:不再由三平台矩阵并发创建 Release,改为先上传构建产物,再由单独发布任务统一创建/更新 Release。
|
||||
- 修复 `Resource not accessible by integration` 导致 Release 创建失败的问题。
|
||||
- Release 仍会从 `CHANGELOG.md` 自动读取当前 tag 的更新日志,并上传 macOS / Windows / Linux 安装包。
|
||||
- 修复 GitHub 版本发布失败的问题。
|
||||
|
||||
## [v0.2.8] - 2026-07-04
|
||||
|
||||
- 更新页进一步产品化:去掉资源/仓库调试信息,将“有更新”改为更明显的绿色标签。
|
||||
- 概览页顶部新增轻量“发现新版本”提示条,可直接打开 Releases 页面。
|
||||
- 新增【会话管理】页面:检查 Codex 本地 sessions / archived_sessions 与 SQLite threads 是否和当前 Provider 同步。
|
||||
- 新增一键 Provider Sync / 修复历史会话:写入前备份到 `~/.codex/backups_state/provider-sync/`,并保留最近 5 份备份。
|
||||
- 简化更新页面,使用醒目的绿色标签标示新版本;概览页新增可直接打开下载页的更新提示条。
|
||||
- 新增“会话管理”页面,可检查历史会话是否与当前供应商匹配,并一键同步修复。
|
||||
- 修复会话前自动备份,保留最近 5 份备份。
|
||||
|
||||
## [v0.2.7] - 2026-07-04
|
||||
|
||||
- 简化更新检查页展示。
|
||||
- “打开下载页”改为打开 GitHub Releases 页面。
|
||||
- 更新弹窗保持简洁,仅提示版本差异。
|
||||
- 首次启动自动检查更新并弹窗提醒。
|
||||
- 简化更新页面和弹窗,“打开下载页”直接前往 GitHub 版本下载页面。
|
||||
- 首次启动自动检查更新,发现新版本时弹窗提醒。
|
||||
|
||||
## [v0.2.5] - 2026-07-04
|
||||
|
||||
- Windows 版双击启动不再额外弹出终端窗口。
|
||||
- 改进 Windows MSI 安装器品牌展示与图标。
|
||||
- About 页面外部链接改为系统默认浏览器打开。
|
||||
- Release 流程加入 Rust cache,后续构建更快。
|
||||
- 修复 Windows 双击启动时额外弹出终端窗口的问题。
|
||||
- 改善 Windows MSI 安装界面的应用名称和图标展示。
|
||||
- “关于”页面的外部链接使用系统默认浏览器打开。
|
||||
|
||||
## [v0.2.4] - 2026-07-04
|
||||
|
||||
- 美化 Windows MSI 安装器横幅与对话图。
|
||||
- Windows 安装包加入应用图标。
|
||||
- 美化 Windows MSI 安装界面,并为安装包加入应用图标。
|
||||
|
||||
## [v0.2.3] - 2026-07-04
|
||||
|
||||
- 修复 macOS DMG 中应用图标缺失问题。
|
||||
- 补充 macOS 安装说明。
|
||||
- Release 流程加入基础缓存优化。
|
||||
- 修复 macOS DMG 中应用图标缺失的问题,并补充安装说明。
|
||||
|
||||
## [v0.2.2] - 2026-07-04
|
||||
|
||||
- macOS / Linux / Windows 首次三平台 Release。
|
||||
- Linux 产物改为 `deb` / `rpm`,避免 AppImage 图标问题。
|
||||
- 首次提供 macOS、Linux 和 Windows 三个平台的安装包。
|
||||
- Linux 改为提供 DEB 和 RPM 安装包。
|
||||
|
||||
## [v0.2.1] - 2026-07-04
|
||||
|
||||
- 首次加入应用图标与 GitHub Release 自动发布。
|
||||
- 首次加入应用图标,并通过 GitHub 提供版本下载。
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows MSI / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows EXE / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -335,7 +335,7 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
|
||||
- macOS Apple Silicon `.dmg`
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows `.exe`(安装到当前用户,后续更新无需管理员权限)
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm` / `.AppImage`
|
||||
- GitHub Releases 自动构建发布
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Third-party notices
|
||||
|
||||
## CC Switch
|
||||
|
||||
Source: https://github.com/farion1231/cc-switch
|
||||
|
||||
Reference commit: `06082e189d65e6d6dbadc35dacdac1ce6c79d89a`.
|
||||
|
||||
Codex-X adapts the circuit-breaker implementation and Codex routing, failover, timeout and configuration behavior from CC Switch. The blocking/local HTTP integration, application state and UI styling are adapted to this project. Relevant source files carry provenance comments.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Young
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Tauri Windows installer template
|
||||
|
||||
Source: https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/templates/installer.nsi
|
||||
|
||||
Codex-X adapts the Tauri CLI v2.11.4 NSIS template for current-user installation, verified legacy MSI migration, update handoff and installation diagnostics.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.16",
|
||||
"version": "0.3.23",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Provider brand marks
|
||||
|
||||
These six unmodified SVG assets are from [Lobe Icons](https://github.com/lobehub/lobe-icons), licensed under the MIT License. The upstream copyright and permission notice is included in [LICENSE](./LICENSE).
|
||||
|
||||
Pinned upstream commit: `a94750e3f5f8fc33757b839d85030e742284e43a`.
|
||||
|
||||
| Local file | Upstream source |
|
||||
| --- | --- |
|
||||
| `deepseek.svg` | [packages/static-svg/icons/deepseek.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/deepseek.svg) |
|
||||
| `minimax.svg` | [packages/static-svg/icons/minimax.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/minimax.svg) |
|
||||
| `mimo.svg` | [packages/static-svg/icons/xiaomimimo.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/xiaomimimo.svg) |
|
||||
| `kimi.svg` | [packages/static-svg/icons/kimi.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/kimi.svg) |
|
||||
| `glm.svg` | [packages/static-svg/icons/zhipu.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/zhipu.svg) |
|
||||
| `qwen.svg` | [packages/static-svg/icons/qwen.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/qwen.svg) |
|
||||
|
||||
The monochrome SVGs are rendered as CSS masks so their color follows the preset card state. Brand names and marks identify their respective providers; they do not imply endorsement.
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>XiaomiMiMo</title><path d="M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
Generated
+80
-1
@@ -438,6 +438,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -490,12 +492,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.16"
|
||||
version = "0.3.23"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"getrandom 0.2.17",
|
||||
"httparse",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"semver",
|
||||
@@ -507,10 +514,14 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml_edit 0.22.27",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip 2.4.2",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1952,6 +1963,16 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
@@ -2678,6 +2699,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2916,6 +2943,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -5605,6 +5656,34 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.0+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.16"
|
||||
version = "0.3.23"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -20,6 +20,7 @@ tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
base64 = "0.22"
|
||||
toml_edit = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5.0"
|
||||
@@ -37,4 +38,14 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
rfd = { version = "=0.17.2", default-features = false, features = ["xdg-portal"] }
|
||||
tauri-plugin-single-instance = "=2.4.3"
|
||||
httparse = "1"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }
|
||||
flate2 = "1"
|
||||
zstd = "0.13"
|
||||
tempfile = "3.27"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"updater:allow-download-and-install",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 4;
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 8;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
@@ -148,6 +148,7 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -207,6 +208,14 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_failover (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
record_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_common_config_state (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
handled_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profiles (
|
||||
codex_dir TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
@@ -219,6 +228,11 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
order_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -240,6 +254,12 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
"source_id",
|
||||
"ALTER TABLE providers ADD COLUMN source_id TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"model_mappings_json",
|
||||
"ALTER TABLE providers ADD COLUMN model_mappings_json TEXT NOT NULL DEFAULT '[]'",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
|
||||
ON providers(source, source_id)
|
||||
@@ -367,6 +387,118 @@ mod tests {
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_four_provider_rows_migrate_with_empty_model_mappings() {
|
||||
let path = test_db_path("provider-model-mappings-v5");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let legacy = Connection::open(&path).unwrap();
|
||||
legacy.execute_batch("CREATE TABLE providers (
|
||||
id TEXT PRIMARY KEY, provider_name TEXT NOT NULL, base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL, api_key TEXT, toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses', requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'manual', source_id TEXT,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
INSERT INTO providers (id, provider_name, base_url, model, api_key, source, source_id, created_at, updated_at)
|
||||
VALUES ('legacy-models', 'Existing provider', 'https://migration.example.test/v1', 'existing-model', 'fixture-key', 'cc-switch', 'source-row', 'original-created', 'original-updated');
|
||||
PRAGMA user_version = 4;").unwrap();
|
||||
drop(legacy);
|
||||
let migrated = DatabaseInitializer::new().open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM provider_common_config_state",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let stored = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert!(stored[0].model_mappings.is_empty());
|
||||
assert_eq!(stored[0].model, "existing-model");
|
||||
assert_eq!(stored[0].api_key.as_deref(), Some("fixture-key"));
|
||||
let metadata: (String, String, String, String, String) = migrated.query_row(
|
||||
"SELECT source, source_id, created_at, updated_at, model_mappings_json FROM providers WHERE id = 'legacy-models'", [],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
metadata,
|
||||
(
|
||||
"cc-switch".into(),
|
||||
"source-row".into(),
|
||||
"original-created".into(),
|
||||
"original-updated".into(),
|
||||
"[]".into()
|
||||
)
|
||||
);
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_seven_database_adds_card_order_preferences_without_changing_provider_data() {
|
||||
let path = test_db_path("provider-card-order-v8");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let legacy = initializer.open_at(&path).unwrap();
|
||||
legacy.execute_batch(
|
||||
"INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, created_at, updated_at)
|
||||
VALUES ('existing', 'Existing provider', 'https://fixture.test/v1', 'fixture-model',
|
||||
'fixture-key', 'original-created', 'original-updated');
|
||||
INSERT INTO official_profiles
|
||||
(codex_dir, id, provider_name, created_at, updated_at)
|
||||
VALUES ('/fixture/config', 'existing-account', 'Existing account', 'created', 'updated');
|
||||
DROP TABLE provider_card_orders;
|
||||
PRAGMA user_version = 7;",
|
||||
)
|
||||
.unwrap();
|
||||
let before = crate::providers::list_saved_providers_on_connection(&legacy).unwrap();
|
||||
drop(legacy);
|
||||
|
||||
let migrated = initializer.open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
crate::providers::list_saved_providers_on_connection(&migrated).unwrap(),
|
||||
before
|
||||
);
|
||||
let timestamps: (String, String) = migrated
|
||||
.query_row(
|
||||
"SELECT created_at, updated_at FROM providers WHERE id = 'existing'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timestamps,
|
||||
("original-created".into(), "original-updated".into())
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT provider_name FROM official_profiles WHERE id = 'existing-account'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"Existing account"
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row("SELECT COUNT(*) FROM provider_card_orders", [], |row| row
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
|
||||
let path = test_db_path("skills-mcp-notes-migration");
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Keep downloads and verification in the signed Tauri updater, but own the
|
||||
//! Windows handoff. The stock updater exits immediately and skips our routing
|
||||
//! shutdown; it also cannot report installation progress after that exit.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{ipc::Channel, Manager, ResourceId, Webview};
|
||||
use tauri_plugin_updater::Update;
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
#[path = "windows_update.rs"]
|
||||
mod windows;
|
||||
|
||||
static UPDATING: AtomicBool = AtomicBool::new(false);
|
||||
const DOWNLOAD_TIMEOUT_MS: u64 = 10 * 60 * 1000;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub(crate) enum AppUpdateEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
content_length: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
chunk_length: usize,
|
||||
},
|
||||
Verifying,
|
||||
#[allow(dead_code)] // Emitted by the Windows handoff; other platforms install in place.
|
||||
Preparing,
|
||||
Installing,
|
||||
#[allow(dead_code)]
|
||||
HandedOff,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum FailureStage {
|
||||
Download,
|
||||
Verify,
|
||||
Prepare,
|
||||
Install,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateFailure {
|
||||
stage: FailureStage,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
log_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InstallResult {
|
||||
restart_required: bool,
|
||||
}
|
||||
|
||||
struct UpdateLease<'a>(&'a AtomicBool);
|
||||
impl<'a> UpdateLease<'a> {
|
||||
fn acquire(flag: &'a AtomicBool) -> Result<Self, UpdateFailure> {
|
||||
flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"已有更新正在进行,请等待当前操作完成。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Ok(Self(flag))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
fn handoff(self) {
|
||||
// Never permit a second installer while the original app is exiting.
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
impl Drop for UpdateLease<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(stage: FailureStage, message: &str, log: Option<&UpdateLog>) -> UpdateFailure {
|
||||
UpdateFailure {
|
||||
stage,
|
||||
message: message.into(),
|
||||
log_path: log.map(|log| log.path.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// This log contains only our fixed stage names and version, never URLs,
|
||||
/// headers, auth/config contents or raw network/parser error messages.
|
||||
struct UpdateLog {
|
||||
path: PathBuf,
|
||||
file: Mutex<File>,
|
||||
}
|
||||
impl UpdateLog {
|
||||
fn create(dir: &Path, version: &str) -> std::io::Result<Self> {
|
||||
let version = semver::Version::parse(version).map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid version")
|
||||
})?;
|
||||
fs::create_dir_all(dir)?;
|
||||
let mut nonce = [0u8; 8];
|
||||
getrandom::getrandom(&mut nonce)
|
||||
.map_err(|_| std::io::Error::other("random source unavailable"))?;
|
||||
let nonce: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let path = dir.join(format!(
|
||||
"update-{}-{}-{nonce}.log",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
std::process::id()
|
||||
));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = Mutex::new(options.open(&path)?);
|
||||
let log = Self { path, file };
|
||||
log.record(&format!("target_version={version}"));
|
||||
Ok(log)
|
||||
}
|
||||
fn record(&self, stage: &str) {
|
||||
if let Ok(mut file) = self.file.lock() {
|
||||
let _ = writeln!(file, "{} {stage}", chrono::Utc::now().to_rfc3339());
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout_ms(value: Option<u64>) -> u64 {
|
||||
value
|
||||
.unwrap_or(DOWNLOAD_TIMEOUT_MS)
|
||||
.clamp(30_000, DOWNLOAD_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/// No installer is launched until all preparation succeeds. If launch fails,
|
||||
/// restore routing while the original app and single-instance lock still live.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn prepared_install<T>(
|
||||
prepare: impl FnOnce() -> Result<(), ()>,
|
||||
launch: impl FnOnce() -> Result<T, ()>,
|
||||
resume: impl FnOnce() -> Result<(), ()>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<T, UpdateFailure> {
|
||||
if prepare().is_err() {
|
||||
log.record("prepare_failed");
|
||||
let resumed = resume().is_ok();
|
||||
log.record(if resumed {
|
||||
"routing_resumed"
|
||||
} else {
|
||||
"routing_resume_failed"
|
||||
});
|
||||
return Err(failure(
|
||||
FailureStage::Prepare,
|
||||
if resumed {
|
||||
"更新前未能恢复路由配置,安装尚未启动。请检查配置文件后重试。"
|
||||
} else {
|
||||
"更新前的路由清理未完成,安装尚未启动。请检查路由设置后重试。"
|
||||
},
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
match launch() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(()) => {
|
||||
log.record("installer_launch_failed");
|
||||
let resumed = resume().is_ok();
|
||||
log.record(if resumed {
|
||||
"routing_resumed"
|
||||
} else {
|
||||
"routing_resume_failed"
|
||||
});
|
||||
Err(failure(
|
||||
FailureStage::Install,
|
||||
if resumed {
|
||||
"无法启动更新安装器,原有路由已恢复。请重试或打开下载页安装。"
|
||||
} else {
|
||||
"无法启动更新安装器。请检查路由设置;也可打开下载页安装。"
|
||||
},
|
||||
Some(log),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_app_update(
|
||||
webview: Webview,
|
||||
update_rid: ResourceId,
|
||||
on_event: Channel<AppUpdateEvent>,
|
||||
timeout: Option<u64>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
let _lease = UpdateLease::acquire(&UPDATING)?;
|
||||
let update = webview
|
||||
.resources_table()
|
||||
.get::<Update>(update_rid)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"更新信息已失效,请重新检查更新。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let mut update = (*update).clone();
|
||||
// The 15s metadata-check timeout must not become the package-download limit.
|
||||
update.timeout = Some(Duration::from_millis(timeout_ms(timeout)));
|
||||
if let Some(headers) = headers {
|
||||
update.headers.clear();
|
||||
for (name, value) in headers {
|
||||
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|_| failure(FailureStage::Prepare, "更新请求参数无效。", None))?;
|
||||
let value = reqwest::header::HeaderValue::from_str(&value)
|
||||
.map_err(|_| failure(FailureStage::Prepare, "更新请求参数无效。", None))?;
|
||||
update.headers.append(name, value);
|
||||
}
|
||||
}
|
||||
let dir = crate::paths::app_home()
|
||||
.map_err(|_| failure(FailureStage::Prepare, "无法读取更新日志目录。", None))?
|
||||
.join("update-logs");
|
||||
let log = UpdateLog::create(&dir, &update.version).map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"无法创建更新日志,请检查文件夹权限后重试。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
log.record("download_started");
|
||||
let finished = AtomicBool::new(false);
|
||||
let mut started = false;
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk_length, content_length| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = on_event.send(AppUpdateEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(AppUpdateEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
finished.store(true, Ordering::Release);
|
||||
log.record("verifying_signature");
|
||||
let _ = on_event.send(AppUpdateEvent::Verifying);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if finished.load(Ordering::Acquire) {
|
||||
log.record("signature_verification_failed");
|
||||
failure(
|
||||
FailureStage::Verify,
|
||||
"更新包校验未通过,安装尚未启动。请重新下载或前往下载页。",
|
||||
Some(&log),
|
||||
)
|
||||
} else {
|
||||
log.record("download_failed");
|
||||
failure(
|
||||
FailureStage::Download,
|
||||
"更新下载未完成或连接超时。请检查网络后重试。",
|
||||
Some(&log),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
log.record("signature_verified");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app = webview.app_handle().clone();
|
||||
let result = install_windows(app, bytes, on_event, log).await;
|
||||
if result.is_ok() {
|
||||
_lease.handoff();
|
||||
}
|
||||
result
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("install_started");
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
update.install(bytes).map_err(|_| {
|
||||
log.record("install_failed");
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重试或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})?;
|
||||
log.record("install_finished_restart_required");
|
||||
Ok(InstallResult {
|
||||
restart_required: true,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重新打开软件后检查更新。",
|
||||
None,
|
||||
)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
// Compile this path in host tests too: staging/Command/IPC use portable Rust,
|
||||
// so Windows handoff type errors are caught before the Windows build starts.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
async fn install_windows(
|
||||
app: tauri::AppHandle,
|
||||
bytes: Vec<u8>,
|
||||
on_event: Channel<AppUpdateEvent>,
|
||||
log: UpdateLog,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Stage bytes before changing live routes. Only signed .exe payloads
|
||||
// from this release pipeline are supported by the new Windows flow.
|
||||
let installer = windows::StagedInstaller::create(&bytes).map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"无法准备 Windows 更新包,请检查磁盘空间或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})?;
|
||||
let _ = on_event.send(AppUpdateEvent::Preparing);
|
||||
log.record("preparing_route_shutdown");
|
||||
prepared_install(
|
||||
|| crate::failover::shutdown_all().map_err(|_| ()),
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("launching_installer");
|
||||
installer.launch(std::process::id()).map_err(|_| ())
|
||||
},
|
||||
|| crate::failover::resume_after_failed_update().map_err(|_| ()),
|
||||
&log,
|
||||
)?;
|
||||
log.record("installer_handed_off");
|
||||
let _ = on_event.send(AppUpdateEvent::HandedOff);
|
||||
// Normal Tauri exit destroys windows/tray/single-instance lock. The
|
||||
// new installer waits for this PID before touching installed files.
|
||||
app.exit(0);
|
||||
Ok(InstallResult {
|
||||
restart_required: false,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"更新准备过程被中断,请重新打开 Codex-X 后检查更新。",
|
||||
None,
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[test]
|
||||
fn one_update_at_a_time_and_errors_release_the_guard() {
|
||||
let flag = AtomicBool::new(false);
|
||||
let first = UpdateLease::acquire(&flag).unwrap();
|
||||
assert!(UpdateLease::acquire(&flag).is_err());
|
||||
drop(first);
|
||||
assert!(UpdateLease::acquire(&flag).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_download_has_its_own_bounded_timeout() {
|
||||
assert_eq!(timeout_ms(None), 600_000);
|
||||
assert_eq!(timeout_ms(Some(15_000)), 30_000);
|
||||
assert_eq!(timeout_ms(Some(u64::MAX)), 600_000);
|
||||
assert_eq!(timeout_ms(Some(120_000)), 120_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_never_launches_on_failed_prepare_and_resumes_on_launch_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let calls = RefCell::new(vec![]);
|
||||
let fail = prepared_install(
|
||||
|| {
|
||||
calls.borrow_mut().push("prepare");
|
||||
Err(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("launch");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("resume");
|
||||
Ok(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(fail.stage, FailureStage::Prepare);
|
||||
assert_eq!(*calls.borrow(), vec!["prepare", "resume"]);
|
||||
calls.borrow_mut().clear();
|
||||
let fail = prepared_install(
|
||||
|| {
|
||||
calls.borrow_mut().push("prepare");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("launch");
|
||||
Err::<(), _>(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("resume");
|
||||
Err(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(fail.stage, FailureStage::Install);
|
||||
assert_eq!(*calls.borrow(), vec!["prepare", "launch", "resume"]);
|
||||
assert!(fail.message.contains("检查路由设置"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_handoff_does_not_resume_routing() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
assert_eq!(
|
||||
prepared_install(|| Ok(()), || Ok(42), || panic!("must not resume"), &log).unwrap(),
|
||||
42
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_are_unique_and_reject_untrusted_version_text() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let first = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let second = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
assert_ne!(first.path, second.path);
|
||||
assert!(UpdateLog::create(temp.path(), "0.4.0\nsecret").is_err());
|
||||
first.record("signature_verified");
|
||||
assert!(fs::read_to_string(&first.path)
|
||||
.unwrap()
|
||||
.contains("signature_verified"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,5 +30,3 @@ pub(crate) const GITHUB_EXAMPLES_API: &str =
|
||||
"https://api.github.com/repos/yynxxxxx/Codex-X/contents/examples?ref=main";
|
||||
pub(crate) const GITHUB_EXAMPLES_BASE: &str =
|
||||
"https://raw.githubusercontent.com/yynxxxxx/Codex-X/main/examples/";
|
||||
|
||||
pub(crate) const MAX_SKILL_ZIP_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, WindowEvent,
|
||||
Emitter, Manager, WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
@@ -123,7 +123,37 @@ pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn report_failover_lifecycle_error(app: &tauri::AppHandle, title: &str, reason: &str) {
|
||||
let title = title.to_string();
|
||||
let reason = reason.to_string();
|
||||
eprintln!("{title}: {reason}");
|
||||
let _ = app.emit("provider-failover-error", &reason);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rfd::AsyncMessageDialog::new()
|
||||
.set_title(title)
|
||||
.set_description(reason)
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = &event {
|
||||
if let Err(error) = crate::failover::shutdown_all() {
|
||||
// Keep the listener alive when restoring the direct route failed.
|
||||
// Exiting here would strand Codex on a local address with no server.
|
||||
api.prevent_exit();
|
||||
restore_main_window(app);
|
||||
report_failover_lifecycle_error(
|
||||
app,
|
||||
"暂时无法退出 Codex-X",
|
||||
&format!("自动切换的连接配置还未恢复,Codex-X 将继续运行。\n\n{error}\n\n请检查配置文件是否被占用,再重试退出。"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = event {
|
||||
restore_main_window(app);
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
// Adapted from CC Switch 06082e189d65e6d6dbadc35dacdac1ce6c79d89a,
|
||||
// src-tauri/src/proxy/circuit_breaker.rs. The state transitions and counters
|
||||
// retain the upstream behavior; locks are synchronous and logs are omitted.
|
||||
//
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2025 Jason Young
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
//! 熔断器模块
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use super::config::RoutingTuning;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
// The upstream state machine is retained; Codex-X forwards on bounded blocking
|
||||
// workers, so its short state locks use std rather than awaiting Tokio locks.
|
||||
struct RwLock<T>(std::sync::RwLock<T>);
|
||||
impl<T> RwLock<T> {
|
||||
fn new(value: T) -> Self {
|
||||
Self(std::sync::RwLock::new(value))
|
||||
}
|
||||
fn read(&self) -> std::sync::RwLockReadGuard<'_, T> {
|
||||
self.0.read().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
fn write(&self) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||
self.0.write().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CircuitState {
|
||||
/// 关闭状态 - 正常工作
|
||||
Closed,
|
||||
/// 打开状态 - 熔断激活,拒绝请求
|
||||
Open,
|
||||
/// 半开状态 - 尝试恢复,允许部分请求通过
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CircuitState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CircuitState::Closed => write!(f, "closed"),
|
||||
CircuitState::Open => write!(f, "open"),
|
||||
CircuitState::HalfOpen => write!(f, "half_open"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerConfig {
|
||||
/// 失败阈值 - 连续失败多少次后打开熔断器
|
||||
pub failure_threshold: u32,
|
||||
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
|
||||
pub success_threshold: u32,
|
||||
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
|
||||
pub timeout_seconds: u64,
|
||||
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
|
||||
pub error_rate_threshold: f64,
|
||||
/// 最小请求数 - 计算错误率前的最小请求数
|
||||
pub min_requests: u32,
|
||||
}
|
||||
|
||||
impl From<&RoutingTuning> for CircuitBreakerConfig {
|
||||
fn from(config: &RoutingTuning) -> Self {
|
||||
Self {
|
||||
failure_threshold: config.circuit_failure_threshold,
|
||||
success_threshold: config.circuit_success_threshold,
|
||||
timeout_seconds: config.circuit_timeout_seconds as u64,
|
||||
error_rate_threshold: config.circuit_error_rate_threshold,
|
||||
min_requests: config.circuit_min_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CircuitBreakerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
failure_threshold: 4,
|
||||
success_threshold: 2,
|
||||
timeout_seconds: 60,
|
||||
error_rate_threshold: 0.6,
|
||||
min_requests: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器实例
|
||||
pub struct CircuitBreaker {
|
||||
/// 当前状态
|
||||
state: Arc<RwLock<CircuitState>>,
|
||||
/// 连续失败计数
|
||||
consecutive_failures: Arc<AtomicU32>,
|
||||
/// 连续成功计数(半开状态)
|
||||
consecutive_successes: Arc<AtomicU32>,
|
||||
/// 总请求计数
|
||||
total_requests: Arc<AtomicU32>,
|
||||
/// 失败请求计数
|
||||
failed_requests: Arc<AtomicU32>,
|
||||
/// 上次打开时间
|
||||
last_opened_at: Arc<RwLock<Option<Instant>>>,
|
||||
/// 配置(支持热更新)
|
||||
config: Arc<RwLock<CircuitBreakerConfig>>,
|
||||
/// 半开状态已放行的请求数(用于限流)
|
||||
half_open_requests: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// 熔断器放行结果
|
||||
///
|
||||
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
|
||||
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AllowResult {
|
||||
pub allowed: bool,
|
||||
pub used_half_open_permit: bool,
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
/// 创建新的熔断器
|
||||
pub fn new(config: CircuitBreakerConfig) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(CircuitState::Closed)),
|
||||
consecutive_failures: Arc::new(AtomicU32::new(0)),
|
||||
consecutive_successes: Arc::new(AtomicU32::new(0)),
|
||||
total_requests: Arc::new(AtomicU32::new(0)),
|
||||
failed_requests: Arc::new(AtomicU32::new(0)),
|
||||
last_opened_at: Arc::new(RwLock::new(None)),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
half_open_requests: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新熔断器配置(热更新,不重置状态)
|
||||
pub fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||
*self.config.write() = new_config;
|
||||
}
|
||||
|
||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||
///
|
||||
/// 这个方法不会占用 HalfOpen 探测名额,仅用于路由选择阶段的“可用性判断”:
|
||||
/// - Closed / HalfOpen:可用(返回 true)
|
||||
/// - Open:若超时到达则切到 HalfOpen 并返回 true,否则返回 false
|
||||
///
|
||||
/// 注意:真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额,
|
||||
/// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。
|
||||
pub fn is_available(&self) -> bool {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
match state {
|
||||
CircuitState::Closed | CircuitState::HalfOpen => true,
|
||||
CircuitState::Open => {
|
||||
if let Some(opened_at) = *self.last_opened_at.read() {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_half_open();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否允许请求通过
|
||||
pub fn allow_request(&self) -> AllowResult {
|
||||
let state = *self.state.read();
|
||||
|
||||
match state {
|
||||
CircuitState::Closed => AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
CircuitState::Open => {
|
||||
let config = self.config.read();
|
||||
// 检查是否应该尝试半开
|
||||
if let Some(opened_at) = *self.last_opened_at.read() {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_half_open();
|
||||
|
||||
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
|
||||
let current_state = *self.state.read();
|
||||
return match current_state {
|
||||
CircuitState::Closed => AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||
CircuitState::Open => AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
}
|
||||
}
|
||||
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录成功
|
||||
pub fn record_success(&self, used_half_open_permit: bool) {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
if used_half_open_permit {
|
||||
self.release_half_open_permit();
|
||||
}
|
||||
|
||||
// 重置失败计数
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if state == CircuitState::HalfOpen {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_closed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录失败
|
||||
pub fn record_failure(&self, used_half_open_permit: bool) {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
if used_half_open_permit {
|
||||
self.release_half_open_permit();
|
||||
}
|
||||
|
||||
// 更新计数器
|
||||
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
self.failed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// 重置成功计数
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
|
||||
// 检查是否应该打开熔断器
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
// HalfOpen 状态下失败,立即转为 Open
|
||||
drop(config);
|
||||
self.transition_to_open();
|
||||
}
|
||||
CircuitState::Closed => {
|
||||
// 检查连续失败次数
|
||||
if failures >= config.failure_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open();
|
||||
} else {
|
||||
// 检查错误率
|
||||
let total = self.total_requests.load(Ordering::SeqCst);
|
||||
let failed = self.failed_requests.load(Ordering::SeqCst);
|
||||
|
||||
if total >= config.min_requests {
|
||||
let error_rate = failed as f64 / total as f64;
|
||||
|
||||
if error_rate >= config.error_rate_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前状态
|
||||
#[allow(dead_code)]
|
||||
pub fn get_state(&self) -> CircuitState {
|
||||
*self.state.read()
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
#[allow(dead_code)]
|
||||
pub fn get_stats(&self) -> CircuitBreakerStats {
|
||||
CircuitBreakerStats {
|
||||
state: *self.state.read(),
|
||||
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
|
||||
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
|
||||
total_requests: self.total_requests.load(Ordering::SeqCst),
|
||||
failed_requests: self.failed_requests.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cooldown_seconds(&self) -> u64 {
|
||||
if *self.state.read() != CircuitState::Open {
|
||||
return 0;
|
||||
}
|
||||
let config = self.config.read();
|
||||
self.last_opened_at.read().as_ref().map_or(0, |opened| {
|
||||
config
|
||||
.timeout_seconds
|
||||
.saturating_sub(opened.elapsed().as_secs())
|
||||
})
|
||||
}
|
||||
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&self) {
|
||||
self.transition_to_closed();
|
||||
}
|
||||
|
||||
fn allow_half_open_probe(&self) -> AllowResult {
|
||||
// 半开状态限流:只允许有限请求通过进行探测
|
||||
let max_half_open_requests = 1u32;
|
||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if current < max_half_open_requests {
|
||||
AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: true,
|
||||
}
|
||||
} else {
|
||||
// 超过限额,回退计数,拒绝请求
|
||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub fn release_half_open_permit(&self) {
|
||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if current == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.half_open_requests.compare_exchange(
|
||||
current,
|
||||
current - 1,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换到打开状态
|
||||
fn transition_to_open(&self) {
|
||||
*self.state.write() = CircuitState::Open;
|
||||
*self.last_opened_at.write() = Some(Instant::now());
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到半开状态
|
||||
fn transition_to_half_open(&self) {
|
||||
let mut state = self.state.write();
|
||||
if *state != CircuitState::Open {
|
||||
return;
|
||||
}
|
||||
|
||||
*state = CircuitState::HalfOpen;
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
// 重置半开状态的请求限流计数
|
||||
self.half_open_requests.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到关闭状态
|
||||
fn transition_to_closed(&self) {
|
||||
*self.state.write() = CircuitState::Closed;
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
// 重置计数器
|
||||
self.total_requests.store(0, Ordering::SeqCst);
|
||||
self.failed_requests.store(0, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerStats {
|
||||
pub state: CircuitState,
|
||||
pub consecutive_failures: u32,
|
||||
pub consecutive_successes: u32,
|
||||
pub total_requests: u32,
|
||||
pub failed_requests: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_closed_to_open() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 初始状态应该是关闭
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
|
||||
// 记录 3 次失败
|
||||
for _ in 0..3 {
|
||||
breaker.record_failure(false);
|
||||
}
|
||||
|
||||
// 应该转换到打开状态
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_half_open_to_closed() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
success_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
|
||||
// 手动转换到半开状态
|
||||
breaker.transition_to_half_open();
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
|
||||
// 记录 2 次成功
|
||||
breaker.record_success(false);
|
||||
breaker.record_success(false);
|
||||
|
||||
// 应该转换到关闭状态
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_half_open_transition_does_not_reset_inflight_permit() {
|
||||
let config = CircuitBreakerConfig {
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 进入 Open,然后由于 timeout_seconds=0,allow_request 会立即切换到 HalfOpen 并占用探测名额
|
||||
breaker.transition_to_open();
|
||||
let first = breaker.allow_request();
|
||||
assert!(first.allowed);
|
||||
assert!(first.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
|
||||
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
|
||||
breaker.transition_to_half_open();
|
||||
|
||||
// 由于名额仍被占用,第二次请求应被拒绝
|
||||
let second = breaker.allow_request();
|
||||
assert!(!second.allowed);
|
||||
assert!(!second.used_half_open_permit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_reset() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
|
||||
// 重置
|
||||
breaker.reset();
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_rate_waits_for_minimum_requests_and_uses_inclusive_threshold() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 20,
|
||||
min_requests: 5,
|
||||
error_rate_threshold: 0.6,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config.clone());
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
assert_eq!(breaker.get_stats().total_requests, 4);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
let higher = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
error_rate_threshold: 0.8,
|
||||
..config.clone()
|
||||
});
|
||||
let minimum = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
min_requests: 10,
|
||||
..config
|
||||
});
|
||||
for breaker in [&higher, &minimum] {
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successes_break_consecutive_failures_without_clearing_error_rate_history() {
|
||||
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
min_requests: 100,
|
||||
..Default::default()
|
||||
});
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert_eq!(breaker.get_stats().failed_requests, 2);
|
||||
assert_eq!(breaker.get_stats().consecutive_failures, 1);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert_eq!(breaker.get_stats().total_requests, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_wait_and_hot_configuration_preserve_existing_evidence() {
|
||||
let mut config = CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config.clone());
|
||||
breaker.record_failure(false);
|
||||
*breaker.last_opened_at.write() = Some(Instant::now() - std::time::Duration::from_secs(1));
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
assert_eq!(breaker.cooldown_seconds(), 1);
|
||||
config.timeout_seconds = 1;
|
||||
breaker.update_config(config);
|
||||
assert_eq!(breaker.get_stats().failed_requests, 1);
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed && probe.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
breaker.release_half_open_permit();
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed);
|
||||
breaker.record_failure(probe.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_open_requires_configured_successes_and_reset_clears_all_statistics() {
|
||||
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
success_threshold: 3,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
});
|
||||
breaker.record_failure(false);
|
||||
for completed in 0..3 {
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed && probe.used_half_open_permit);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
breaker.record_success(probe.used_half_open_permit);
|
||||
assert_eq!(
|
||||
breaker.get_state(),
|
||||
if completed < 2 {
|
||||
CircuitState::HalfOpen
|
||||
} else {
|
||||
CircuitState::Closed
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(breaker.get_stats().total_requests, 0);
|
||||
breaker.record_failure(false);
|
||||
breaker.reset();
|
||||
let stats = breaker.get_stats();
|
||||
assert_eq!(
|
||||
(
|
||||
stats.total_requests,
|
||||
stats.failed_requests,
|
||||
stats.consecutive_failures,
|
||||
stats.consecutive_successes
|
||||
),
|
||||
(0, 0, 0, 0)
|
||||
);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Codex defaults/ranges follow CC Switch 06082e1 AppProxyConfig and its
|
||||
//! AutoFailoverConfigPanel. Both IPC and the UI validate these settings.
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
pub(crate) const DEFAULT_LISTEN_ADDRESS: &str = "127.0.0.1";
|
||||
pub(crate) const DEFAULT_LISTEN_PORT: u16 = 15721;
|
||||
pub(crate) const MAX_QUEUE: usize = 64;
|
||||
pub(crate) const ROUTE_TOKEN_HEADER: &str = "x-codex-x-route-token";
|
||||
pub(crate) const ROUTE_GENERATION_HEADER: &str = "x-codex-x-route-generation";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub(crate) struct RoutingTuning {
|
||||
pub(crate) max_retries: u32,
|
||||
pub(crate) streaming_first_byte_timeout: u64,
|
||||
pub(crate) streaming_idle_timeout: u64,
|
||||
pub(crate) non_streaming_timeout: u64,
|
||||
pub(crate) circuit_failure_threshold: u32,
|
||||
pub(crate) circuit_success_threshold: u32,
|
||||
pub(crate) circuit_timeout_seconds: u64,
|
||||
pub(crate) circuit_error_rate_threshold: f64,
|
||||
pub(crate) circuit_min_requests: u32,
|
||||
}
|
||||
|
||||
impl Default for RoutingTuning {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 3,
|
||||
streaming_first_byte_timeout: 60,
|
||||
streaming_idle_timeout: 120,
|
||||
non_streaming_timeout: 600,
|
||||
circuit_failure_threshold: 4,
|
||||
circuit_success_threshold: 2,
|
||||
circuit_timeout_seconds: 60,
|
||||
circuit_error_rate_threshold: 0.6,
|
||||
circuit_min_requests: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutingTuning {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
for (valid, message) in [
|
||||
(self.max_retries <= 10, "最大重试次数须为 0–10"),
|
||||
(
|
||||
(1..=120).contains(&self.streaming_first_byte_timeout),
|
||||
"流式首字节超时须为 1–120 秒",
|
||||
),
|
||||
(
|
||||
self.streaming_idle_timeout <= 600,
|
||||
"流式静默超时须为 0–600 秒,0 表示禁用",
|
||||
),
|
||||
(
|
||||
(60..=1200).contains(&self.non_streaming_timeout),
|
||||
"非流式超时须为 60–1200 秒",
|
||||
),
|
||||
(
|
||||
(1..=20).contains(&self.circuit_failure_threshold),
|
||||
"失败阈值须为 1–20",
|
||||
),
|
||||
(
|
||||
(1..=10).contains(&self.circuit_success_threshold),
|
||||
"恢复成功阈值须为 1–10",
|
||||
),
|
||||
(
|
||||
self.circuit_timeout_seconds <= 300,
|
||||
"恢复等待时间须为 0–300 秒",
|
||||
),
|
||||
(
|
||||
self.circuit_error_rate_threshold.is_finite()
|
||||
&& (0.0..=1.0).contains(&self.circuit_error_rate_threshold),
|
||||
"错误率阈值须为 0–100%",
|
||||
),
|
||||
(
|
||||
(5..=100).contains(&self.circuit_min_requests),
|
||||
"最小请求数须为 5–100",
|
||||
),
|
||||
] {
|
||||
if !valid {
|
||||
return Err(CodexxError::Config(message.into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn listen_ip(address: &str) -> Result<IpAddr> {
|
||||
let address = address.trim();
|
||||
if address.eq_ignore_ascii_case("localhost") {
|
||||
return Ok(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
}
|
||||
let address = address
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.strip_suffix(']'))
|
||||
.unwrap_or(address);
|
||||
address
|
||||
.parse()
|
||||
.map_err(|_| CodexxError::Config("监听地址须为 IPv4、IPv6 或 localhost".into()))
|
||||
}
|
||||
|
||||
pub(crate) fn client_ip(address: IpAddr) -> IpAddr {
|
||||
match address {
|
||||
IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
address => address,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local_url(address: IpAddr, port: u16) -> String {
|
||||
match client_ip(address) {
|
||||
IpAddr::V4(ip) => format!("http://{ip}:{port}/v1"),
|
||||
IpAddr::V6(ip) => format!("http://[{ip}]:{port}/v1"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn codex_defaults_and_valid_boundaries_match_reference() {
|
||||
let defaults = RoutingTuning::default();
|
||||
defaults.validate().unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
defaults.max_retries,
|
||||
defaults.streaming_first_byte_timeout,
|
||||
defaults.streaming_idle_timeout,
|
||||
defaults.non_streaming_timeout
|
||||
),
|
||||
(3, 60, 120, 600)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
defaults.circuit_failure_threshold,
|
||||
defaults.circuit_success_threshold,
|
||||
defaults.circuit_timeout_seconds,
|
||||
defaults.circuit_min_requests
|
||||
),
|
||||
(4, 2, 60, 10)
|
||||
);
|
||||
assert_eq!(defaults.circuit_error_rate_threshold, 0.6);
|
||||
RoutingTuning {
|
||||
max_retries: 0,
|
||||
streaming_first_byte_timeout: 1,
|
||||
streaming_idle_timeout: 0,
|
||||
non_streaming_timeout: 60,
|
||||
circuit_failure_threshold: 1,
|
||||
circuit_success_threshold: 1,
|
||||
circuit_timeout_seconds: 0,
|
||||
circuit_error_rate_threshold: 0.0,
|
||||
circuit_min_requests: 5,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
RoutingTuning {
|
||||
max_retries: 10,
|
||||
streaming_first_byte_timeout: 120,
|
||||
streaming_idle_timeout: 600,
|
||||
non_streaming_timeout: 1200,
|
||||
circuit_failure_threshold: 20,
|
||||
circuit_success_threshold: 10,
|
||||
circuit_timeout_seconds: 300,
|
||||
circuit_error_rate_threshold: 1.0,
|
||||
circuit_min_requests: 100,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn invalid_tuning_is_rejected_in_backend() {
|
||||
for invalid in [
|
||||
RoutingTuning {
|
||||
max_retries: 11,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_first_byte_timeout: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_first_byte_timeout: 121,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_idle_timeout: 601,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
non_streaming_timeout: 59,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
non_streaming_timeout: 1201,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_failure_threshold: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_failure_threshold: 21,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_success_threshold: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_success_threshold: 11,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_timeout_seconds: 301,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_error_rate_threshold: f64::NAN,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_error_rate_threshold: 1.1,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_min_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_min_requests: 101,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(invalid.validate().is_err(), "{invalid:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn listen_addresses_and_client_urls_support_ipv4_ipv6_and_wildcards() {
|
||||
assert_eq!(listen_ip("localhost").unwrap().to_string(), "127.0.0.1");
|
||||
assert_eq!(
|
||||
local_url(listen_ip("0.0.0.0").unwrap(), 15721),
|
||||
"http://127.0.0.1:15721/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
local_url(listen_ip("::").unwrap(), 15721),
|
||||
"http://[::1]:15721/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
local_url(listen_ip("[::1]").unwrap(), 15721),
|
||||
"http://[::1]:15721/v1"
|
||||
);
|
||||
for value in [
|
||||
"256.0.0.1",
|
||||
"host.example",
|
||||
"127.0.0.1:15721",
|
||||
"::1/path",
|
||||
"",
|
||||
] {
|
||||
assert!(listen_ip(value).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
mod circuit_breaker;
|
||||
pub(crate) mod config;
|
||||
mod controller;
|
||||
pub(crate) mod native_official;
|
||||
mod proxy;
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
pub(crate) use controller::resume_after_failed_update;
|
||||
|
||||
pub(crate) use controller::{
|
||||
attach_app_handle, direct_document, get_status, initialize, recover_stale_route,
|
||||
refresh_saved_routes, reset_health, save_settings, shutdown_all, with_provider_change,
|
||||
FailoverSettings, FailoverStatus,
|
||||
};
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Native Codex official routing. Codex remains the owner of its login and token
|
||||
//! refresh; this layer only verifies the selected live login and forwards it to
|
||||
//! its fixed official origin. No credential is loaded from an inactive profile.
|
||||
|
||||
use super::proxy::ProxyRoute;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::providers::document_is_official;
|
||||
use crate::providers::official_profiles::{list_official_profiles_inner, selected_profile_id};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
const CHATGPT_BASE: &str = "https://chatgpt.com/backend-api/codex";
|
||||
const OPENAI_API_BASE: &str = "https://api.openai.com/v1";
|
||||
const MAX_CONFIG_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_AUTH_BYTES: usize = 1024 * 1024;
|
||||
const MAX_CATALOG_BYTES: usize = 1024 * 1024;
|
||||
const MAX_TOKEN_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct OfficialRouteSpec {
|
||||
pub(crate) codex_dir: PathBuf,
|
||||
pub(crate) profile_id: String,
|
||||
}
|
||||
|
||||
// Intentionally not Debug/Serialize: these values must never appear in status,
|
||||
// frontend events, or error logs.
|
||||
pub(crate) struct OfficialRequestAuth {
|
||||
pub(crate) authorization: String,
|
||||
pub(crate) account_id: Option<String>,
|
||||
pub(crate) base_url: String,
|
||||
}
|
||||
|
||||
struct LiveAuth {
|
||||
token: String,
|
||||
account_id: Option<String>,
|
||||
base_url: &'static str,
|
||||
}
|
||||
|
||||
fn error(message: &str) -> CodexxError {
|
||||
CodexxError::Config(message.to_owned())
|
||||
}
|
||||
|
||||
fn read_bounded(path: &Path, limit: usize, missing_allowed: bool) -> Result<Vec<u8>> {
|
||||
let file = match fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if missing_allowed && err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new())
|
||||
}
|
||||
Err(_) => return Err(error("无法读取当前官方登录配置,请检查文件后重试")),
|
||||
};
|
||||
if !file
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.is_file() && metadata.len() <= limit as u64)
|
||||
{
|
||||
return Err(error("当前官方登录配置无法读取,请检查文件大小和格式"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(limit as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| error("读取当前官方登录配置失败,请重试"))?;
|
||||
if bytes.len() > limit {
|
||||
return Err(error("当前官方登录配置过大,请检查文件"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn logical_document(dir: &Path) -> Result<(Vec<u8>, DocumentMut)> {
|
||||
let bytes = read_bounded(&crate::config_path(dir), MAX_CONFIG_BYTES, true)?;
|
||||
let text = std::str::from_utf8(&bytes)
|
||||
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
|
||||
let doc = text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
|
||||
let doc = super::direct_document(dir, &doc)
|
||||
.map_err(|_| error("无法确认当前官方路由,请检查本地路由状态"))?;
|
||||
Ok((bytes, doc))
|
||||
}
|
||||
|
||||
fn checked_secret(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?.as_str()?.trim();
|
||||
(!value.is_empty()
|
||||
&& value.len() <= MAX_TOKEN_BYTES
|
||||
&& value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
&& value != "PROXY_MANAGED")
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn checked_account(value: Option<&str>) -> Result<Option<String>> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.len() > 512 || !value.bytes().all(|byte| byte.is_ascii_graphic()) {
|
||||
return Err(error("当前官方账号信息无效,请重新登录 Codex"));
|
||||
}
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_live_auth(bytes: &[u8]) -> Result<LiveAuth> {
|
||||
let auth: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| error("官方账号尚未登录或认证无效,请在 Codex 中重新登录"))?;
|
||||
if !auth.is_object()
|
||||
|| ["base_url", "baseUrl", "api_base", "endpoint"]
|
||||
.iter()
|
||||
.any(|key| auth.get(key).is_some())
|
||||
{
|
||||
return Err(error("当前官方认证格式无效,请在 Codex 中重新登录"));
|
||||
}
|
||||
let mode = auth.get("auth_mode").and_then(Value::as_str);
|
||||
let api_key = checked_secret(auth.get("OPENAI_API_KEY"));
|
||||
let tokens = auth.get("tokens");
|
||||
let has_tokens = tokens.is_some_and(|value| {
|
||||
!value.is_null() && value.as_object().is_none_or(|values| !values.is_empty())
|
||||
});
|
||||
if let Some(token) = api_key {
|
||||
if mode.is_some_and(|value| !value.eq_ignore_ascii_case("apikey")) || has_tokens {
|
||||
return Err(error("官方登录与 API Key 认证混用,请在 Codex 中重新登录"));
|
||||
}
|
||||
return Ok(LiveAuth {
|
||||
token,
|
||||
account_id: None,
|
||||
base_url: OPENAI_API_BASE,
|
||||
});
|
||||
}
|
||||
if auth.get("OPENAI_API_KEY").is_some_and(|value| {
|
||||
!value.is_null() && value.as_str().is_none_or(|text| !text.trim().is_empty())
|
||||
}) {
|
||||
return Err(error("当前 OpenAI API Key 无效,请重新设置"));
|
||||
}
|
||||
if mode.is_some_and(|value| {
|
||||
!value.eq_ignore_ascii_case("chatgpt") && !value.eq_ignore_ascii_case("chatgptAuthTokens")
|
||||
}) {
|
||||
return Err(error(
|
||||
"此登录方式暂不支持本地路由,请使用 ChatGPT 登录或 OpenAI API Key",
|
||||
));
|
||||
}
|
||||
let token = checked_secret(auth.pointer("/tokens/access_token"))
|
||||
.ok_or_else(|| error("官方账号尚未登录,请在 Codex 中完成 ChatGPT 登录"))?;
|
||||
let account_id = checked_account(auth.pointer("/tokens/account_id").and_then(Value::as_str))?;
|
||||
Ok(LiveAuth {
|
||||
token,
|
||||
account_id,
|
||||
base_url: CHATGPT_BASE,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_selected(spec: &OfficialRouteSpec) -> Result<(Vec<u8>, DocumentMut)> {
|
||||
let (bytes, doc) = logical_document(&spec.codex_dir)?;
|
||||
if !document_is_official(&doc)
|
||||
|| selected_profile_id(&spec.codex_dir)
|
||||
.map_err(|_| error("无法确认当前官方账号,请刷新后重试"))?
|
||||
!= spec.profile_id
|
||||
{
|
||||
return Err(error("当前官方账号已切换,请重启 Codex 或新建会话后重试"));
|
||||
}
|
||||
Ok((bytes, doc))
|
||||
}
|
||||
|
||||
pub(crate) fn route_for_current(dir: &Path) -> Result<Option<ProxyRoute>> {
|
||||
let (_, doc) = logical_document(dir)?;
|
||||
if !document_is_official(&doc) {
|
||||
return Ok(None);
|
||||
}
|
||||
let profile_id = selected_profile_id(dir)?;
|
||||
let name = list_official_profiles_inner(Some(dir.display().to_string()))?
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == profile_id)
|
||||
.map(|profile| profile.provider_name)
|
||||
.unwrap_or_else(|| "OpenAI Official".to_owned());
|
||||
// A logged-out official route can still be taken over. Codex may complete
|
||||
// login or refresh after startup; every request re-reads and verifies it.
|
||||
let base_url = read_bounded(&crate::auth_path(dir), MAX_AUTH_BYTES, true)
|
||||
.ok()
|
||||
.and_then(|bytes| parse_live_auth(&bytes).ok())
|
||||
.map_or(CHATGPT_BASE, |auth| auth.base_url)
|
||||
.to_owned();
|
||||
let models = crate::string_value(&doc, "model")
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(Some(ProxyRoute {
|
||||
id: format!("official:{profile_id}"),
|
||||
name,
|
||||
base_url,
|
||||
api_key: None,
|
||||
headers: Vec::new(),
|
||||
models,
|
||||
official: Some(OfficialRouteSpec {
|
||||
codex_dir: dir.to_path_buf(),
|
||||
profile_id,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn verify_request(
|
||||
spec: &OfficialRouteSpec,
|
||||
authorization: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<OfficialRequestAuth> {
|
||||
let (config_before, _) = ensure_selected(spec)?;
|
||||
if authorization.len() > MAX_TOKEN_BYTES + 32 || authorization.chars().any(char::is_control) {
|
||||
return Err(error("官方请求认证无效,请在 Codex 中重新登录"));
|
||||
}
|
||||
let parts: Vec<_> = authorization.split_whitespace().collect();
|
||||
if parts.len() != 2 || !parts[0].eq_ignore_ascii_case("Bearer") {
|
||||
return Err(error("缺少官方请求认证,请在 Codex 中完成登录"));
|
||||
}
|
||||
let auth_before = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
|
||||
let auth = parse_live_auth(&auth_before)?;
|
||||
if parts[1] != auth.token || checked_account(account_id)? != auth.account_id {
|
||||
return Err(error(
|
||||
"此会话没有加载当前官方账号,请重启 Codex 或新建会话后重试",
|
||||
));
|
||||
}
|
||||
// Avoid trusting a token across a simultaneous account switch/logout/refresh.
|
||||
// This reads files only; acquiring the live mutation lock here would deadlock
|
||||
// callers constructing routes while holding their own configuration guard.
|
||||
let (config_after, _) = ensure_selected(spec)?;
|
||||
let auth_after = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
|
||||
if config_before != config_after || auth_before != auth_after {
|
||||
return Err(error("官方登录正在更新,请稍后重试"));
|
||||
}
|
||||
Ok(OfficialRequestAuth {
|
||||
authorization: format!("Bearer {}", auth.token),
|
||||
account_id: auth.account_id,
|
||||
base_url: auth.base_url.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_link(metadata: &fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_type().is_symlink() || metadata.file_attributes() & 0x400 != 0
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn native_models(spec: &OfficialRouteSpec) -> Result<Value> {
|
||||
let (_, doc) = ensure_selected(spec)?;
|
||||
let Some(pointer) = doc.get("model_catalog_json").and_then(|item| item.as_str()) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let path = Path::new(pointer);
|
||||
if path
|
||||
.components()
|
||||
.any(|part| matches!(part, Component::ParentDir))
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
let path = if path.is_absolute() {
|
||||
path.to_owned()
|
||||
} else {
|
||||
spec.codex_dir.join(path)
|
||||
};
|
||||
let owned = spec.codex_dir.join(".codex-x").join("model-catalogs");
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
if !name
|
||||
.strip_suffix(".json")
|
||||
.is_some_and(|hash| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
for directory in [spec.codex_dir.join(".codex-x"), owned.clone()] {
|
||||
if !fs::symlink_metadata(directory)
|
||||
.is_ok_and(|metadata| !is_link(&metadata) && metadata.is_dir())
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
}
|
||||
if !fs::symlink_metadata(&path).is_ok_and(|metadata| !is_link(&metadata) && metadata.is_file())
|
||||
|| !path
|
||||
.parent()
|
||||
.and_then(|parent| parent.canonicalize().ok())
|
||||
.zip(owned.canonicalize().ok())
|
||||
.is_some_and(|(parent, owned)| parent == owned)
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
let Ok(bytes) = read_bounded(&path, MAX_CATALOG_BYTES, false) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x-provider-model-catalog-v2\0");
|
||||
digest.update(serde_json::to_vec(&value).map_err(|_| error("本地模型目录格式无效"))?);
|
||||
if value
|
||||
.pointer("/_codex_x_model_catalog/source")
|
||||
.and_then(Value::as_str)
|
||||
!= Some("codex-x")
|
||||
|| !value.get("models").is_some_and(Value::is_array)
|
||||
|| format!("{:x}.json", digest.finalize()) != name
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::providers::official_profiles::{
|
||||
save_official_profile_inner, switch_official_profile_inner, OfficialProfileInput,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-native-official-{}-{}",
|
||||
std::process::id(),
|
||||
COUNT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(crate::config_path(&dir), "model_provider = 'custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nrequires_openai_auth=true\nsupports_websockets=true\nwire_api='responses'\n[mcp_servers.keep]\ncommand='fixture-mcp'\n").unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
fn oauth(&self, access: &str, account: &str) {
|
||||
fs::write(crate::auth_path(&self.dir), serde_json::to_vec(&json!({"auth_mode":"chatgpt","tokens":{"access_token":access,"refresh_token":"unused-refresh-fixture","account_id":account}})).unwrap()).unwrap();
|
||||
}
|
||||
fn spec(&self) -> OfficialRouteSpec {
|
||||
OfficialRouteSpec {
|
||||
codex_dir: self.dir.clone(),
|
||||
profile_id: DEFAULT_OFFICIAL_PROFILE_ID.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_checks_exact_live_token_even_for_team_members_sharing_workspace() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("live-user-a-token", "shared-team-workspace");
|
||||
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
let config_before = fs::read(crate::config_path(&fixture.dir)).unwrap();
|
||||
let spec = fixture.spec();
|
||||
let allowed = verify_request(
|
||||
&spec,
|
||||
"Bearer live-user-a-token",
|
||||
Some("shared-team-workspace"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(allowed.base_url, CHATGPT_BASE);
|
||||
assert_eq!(allowed.account_id.as_deref(), Some("shared-team-workspace"));
|
||||
for (authorization, account) in [
|
||||
("Bearer another-user-token", Some("shared-team-workspace")),
|
||||
("Bearer live-user-a-token", Some("other-workspace")),
|
||||
("Bearer live-user-a-token", None),
|
||||
("Bearer PROXY_MANAGED", Some("shared-team-workspace")),
|
||||
("Bearer\nlive-user-a-token", Some("shared-team-workspace")),
|
||||
("", Some("shared-team-workspace")),
|
||||
] {
|
||||
let failure = verify_request(&spec, authorization, account)
|
||||
.err()
|
||||
.expect("must reject stale or wrong account credentials")
|
||||
.to_string();
|
||||
assert!(!failure.contains("live-user-a-token"));
|
||||
assert!(!failure.contains("another-user-token"));
|
||||
assert!(!failure.contains("shared-team-workspace"));
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(crate::config_path(&fixture.dir)).unwrap(),
|
||||
config_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_login_and_token_refresh_are_owned_by_codex_without_cached_credentials() {
|
||||
let fixture = Fixture::new();
|
||||
let route = route_for_current(&fixture.dir).unwrap().unwrap();
|
||||
assert!(route.api_key.is_none());
|
||||
assert!(route.headers.is_empty());
|
||||
let spec = route.official.unwrap();
|
||||
assert!(verify_request(&spec, "Bearer absent", None).is_err());
|
||||
fixture.oauth("first-token", "account");
|
||||
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_ok());
|
||||
fixture.oauth("refreshed-token", "account");
|
||||
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_err());
|
||||
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_ok());
|
||||
let latest = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
route_for_current(&fixture.dir).unwrap();
|
||||
assert_eq!(fs::read(crate::auth_path(&fixture.dir)).unwrap(), latest);
|
||||
fs::remove_file(crate::auth_path(&fixture.dir)).unwrap();
|
||||
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_err());
|
||||
assert!(route_for_current(&fixture.dir).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_does_not_reuse_inactive_profile_or_third_party_oauth() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("account-a-token", "workspace");
|
||||
let scope = Some(fixture.dir.display().to_string());
|
||||
let other = save_official_profile_inner(OfficialProfileInput {
|
||||
config_dir: scope.clone(), id: None, provider_name: "Account B".into(),
|
||||
model: Some("official-model".into()), config_text: Some(fs::read_to_string(crate::config_path(&fixture.dir)).unwrap()),
|
||||
auth_json: Some(json!({"auth_mode":"chatgpt","tokens":{"access_token":"account-b-token","account_id":"workspace"}}).to_string()),
|
||||
}).unwrap();
|
||||
let spec = fixture.spec();
|
||||
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
switch_official_profile_inner(scope, other.profile.id.clone()).unwrap();
|
||||
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
let current = route_for_current(&fixture.dir)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.official
|
||||
.unwrap();
|
||||
assert_eq!(current.profile_id, other.profile.id);
|
||||
assert!(verify_request(¤t, "Bearer account-b-token", Some("workspace")).is_ok());
|
||||
fs::write(crate::config_path(&fixture.dir), "model_provider='custom'\nmodel='third'\n[model_providers.custom]\nname='Third'\nbase_url='https://third.example.test/v1'\nrequires_openai_auth=false\n").unwrap();
|
||||
assert!(route_for_current(&fixture.dir).unwrap().is_none());
|
||||
assert!(verify_request(¤t, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_api_key_uses_only_openai_api_origin_and_never_accepts_oauth_or_mixed_auth() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(
|
||||
crate::auth_path(&fixture.dir),
|
||||
json!({"auth_mode":"apikey","OPENAI_API_KEY":"api-key-fixture","tokens":null})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let spec = fixture.spec();
|
||||
let verified = verify_request(&spec, "Bearer api-key-fixture", None).unwrap();
|
||||
assert_eq!(verified.base_url, OPENAI_API_BASE);
|
||||
assert!(verified.account_id.is_none());
|
||||
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
|
||||
assert!(
|
||||
verify_request(&spec, "Bearer api-key-fixture", Some("old-oauth-account")).is_err()
|
||||
);
|
||||
fs::write(
|
||||
crate::auth_path(&fixture.dir),
|
||||
json!({"OPENAI_API_KEY":"api-key-fixture","tokens":{"access_token":"oauth-fixture"}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(verify_request(&spec, "Bearer api-key-fixture", None).is_err());
|
||||
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
|
||||
fs::write(crate::auth_path(&fixture.dir), "{ broken-secret-json").unwrap();
|
||||
let failure = verify_request(&spec, "Bearer api-key-fixture", None)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(!failure.contains("broken-secret-json"));
|
||||
assert!(!failure.contains("api-key-fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_models_serve_only_verified_owned_catalog_and_never_read_arbitrary_json() {
|
||||
let fixture = Fixture::new();
|
||||
let spec = fixture.spec();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
let config_path = crate::config_path(&fixture.dir);
|
||||
let mut doc = fs::read_to_string(&config_path)
|
||||
.unwrap()
|
||||
.parse::<DocumentMut>()
|
||||
.unwrap();
|
||||
crate::providers::model_catalog::prepare_model_catalog(
|
||||
&fixture.dir,
|
||||
"native-test",
|
||||
&[crate::providers::model_catalog::ProviderModelMapping {
|
||||
model: "local-model".into(),
|
||||
display_name: "Local".into(),
|
||||
context_window: None,
|
||||
}],
|
||||
"local-model",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
let owned = PathBuf::from(doc["model_catalog_json"].as_str().unwrap());
|
||||
let before = fs::read(&config_path).unwrap();
|
||||
assert_eq!(
|
||||
native_models(&spec).unwrap()["models"][0]["slug"],
|
||||
"local-model"
|
||||
);
|
||||
assert_eq!(fs::read(&config_path).unwrap(), before);
|
||||
let outside = fixture.dir.join("unrelated-auth.json");
|
||||
fs::write(&outside, r#"{"models":["must-not-leak"]}"#).unwrap();
|
||||
doc["model_catalog_json"] = toml_edit::value(outside.display().to_string());
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::remove_file(&owned).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, &owned).unwrap();
|
||||
doc["model_catalog_json"] = toml_edit::value(owned.display().to_string());
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_local_models_endpoint_accepts_owned_live_oauth_without_network_refresh_or_fallback() {
|
||||
use super::super::config::{RoutingTuning, ROUTE_TOKEN_HEADER};
|
||||
use super::super::proxy::{ProxyHandle, ProxyOptions};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::time::Duration;
|
||||
struct Server(ProxyHandle);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.0.shutdown();
|
||||
}
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("local-native-access", "local-native-account");
|
||||
let route = route_for_current(&fixture.dir).unwrap().unwrap();
|
||||
let local_token = "native-local-route-token-fixture-000000000000";
|
||||
let server = Server(
|
||||
ProxyHandle::start(
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
0,
|
||||
local_token.into(),
|
||||
vec![route],
|
||||
ProxyOptions {
|
||||
auto_failover_enabled: true,
|
||||
tuning: RoutingTuning::default(),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
for access in ["local-native-access", "local-native-refreshed"] {
|
||||
fixture.oauth(access, "local-native-account");
|
||||
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
let response = client
|
||||
.get(format!("http://127.0.0.1:{}/v1/models", server.0.port()))
|
||||
.header(ROUTE_TOKEN_HEADER, local_token)
|
||||
.header("authorization", format!("Bearer {access}"))
|
||||
.header("chatgpt-account-id", "local-native-account")
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
let body: Value = serde_json::from_str(&response.text().unwrap()).unwrap();
|
||||
assert_eq!(body, json!({"models":[]}));
|
||||
assert_eq!(
|
||||
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
}
|
||||
let snapshot = server.0.snapshot();
|
||||
assert_eq!(snapshot.success_count, 2);
|
||||
assert_eq!(snapshot.failover_count, 0);
|
||||
assert_eq!(
|
||||
snapshot.last_provider_id.as_deref(),
|
||||
Some("official:openai-official")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,824 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
providers: Vec<SavedProvider>,
|
||||
original: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let number = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let tag = format!("failover-test-{}-{number}", std::process::id());
|
||||
let dir = std::env::temp_dir().join(&tag);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let dir = dir.canonicalize().unwrap();
|
||||
let mut providers = Vec::new();
|
||||
for index in 0..3 {
|
||||
let provider = SavedProvider {
|
||||
id: format!("{tag}-{index}"),
|
||||
provider_name: format!("Provider {index}"),
|
||||
base_url: format!("https://{tag}-{index}.example.test/v1"),
|
||||
model: "same-model".into(),
|
||||
api_key: Some(format!("fixture-provider-secret-{index}")),
|
||||
toml_config: None,
|
||||
wire_api: "responses".into(),
|
||||
requires_openai_auth: false,
|
||||
model_mappings: vec![],
|
||||
};
|
||||
providers.push(crate::providers::save_provider_inner(provider).unwrap());
|
||||
}
|
||||
let primary = &providers[0];
|
||||
let original = format!("# preserve user config\nmodel_provider = \"custom\"\nmodel = \"same-model\"\nmodel_reasoning_effort = \"high\"\n\n[model_providers.custom]\nname = \"Provider 0\"\nbase_url = {:?}\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = true\nexperimental_bearer_token = {:?}\nrequest_max_retries = 4\n\n[model_providers.custom.http_headers]\nx-fixture = \"primary-header\"\n\n[mcp_servers.fixture]\ncommand = \"fixture-tool\"\n", primary.base_url, primary.api_key.as_deref().unwrap());
|
||||
fs::write(crate::config_path(&dir), &original).unwrap();
|
||||
fs::write(
|
||||
dir.join("auth.json"),
|
||||
"{\"OPENAI_API_KEY\":\"untouched-official-auth-fixture\"}",
|
||||
)
|
||||
.unwrap();
|
||||
crate::providers::remember_active_provider_on_connection(
|
||||
&crate::app_db::open().unwrap(),
|
||||
&dir,
|
||||
&primary.id,
|
||||
)
|
||||
.unwrap();
|
||||
Self {
|
||||
dir,
|
||||
providers,
|
||||
original,
|
||||
port: free_port(),
|
||||
}
|
||||
}
|
||||
fn scope(&self) -> Option<String> {
|
||||
Some(self.dir.display().to_string())
|
||||
}
|
||||
fn settings(&self) -> FailoverSettings {
|
||||
FailoverSettings {
|
||||
router_enabled: true,
|
||||
takeover_enabled: true,
|
||||
auto_failover_enabled: true,
|
||||
listen_port: self.port,
|
||||
provider_ids: vec![self.providers[0].id.clone(), self.providers[1].id.clone()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn text(&self) -> String {
|
||||
fs::read_to_string(crate::config_path(&self.dir)).unwrap()
|
||||
}
|
||||
fn enable(&self) -> FailoverStatus {
|
||||
save_settings(self.scope(), self.settings()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
// If a test deliberately left invalid TOML, repair only its private fixture.
|
||||
if let Ok(record) = load_record(&self.dir) {
|
||||
if let Ok(mut runtimes) = lock_manager() {
|
||||
if let Some(runtime) = runtimes.remove(&self.dir) {
|
||||
runtime.proxy.shutdown();
|
||||
}
|
||||
}
|
||||
let _ = record;
|
||||
}
|
||||
if let Ok(conn) = crate::app_db::open() {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM provider_failover WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(&self.dir)],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(&self.dir)],
|
||||
);
|
||||
for provider in &self.providers {
|
||||
let _ = conn.execute("DELETE FROM providers WHERE id = ?1", [&provider.id]);
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
fn stop(fixture: &Fixture) {
|
||||
let mut settings = load_record(&fixture.dir).unwrap().settings;
|
||||
settings.router_enabled = false;
|
||||
save_settings(fixture.scope(), settings).unwrap();
|
||||
}
|
||||
fn parsed(fixture: &Fixture) -> DocumentMut {
|
||||
fixture.text().parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_takeover_and_auto_switches_are_independent() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.takeover_enabled = false;
|
||||
settings.auto_failover_enabled = false;
|
||||
let listening = save_settings(fixture.scope(), settings.clone()).unwrap();
|
||||
assert!(listening.running);
|
||||
assert!(!listening.takeover_active);
|
||||
assert!(!listening.auto_failover_active);
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
settings.takeover_enabled = true;
|
||||
let single = save_settings(fixture.scope(), settings.clone()).unwrap();
|
||||
assert!(single.takeover_active);
|
||||
assert!(!single.auto_failover_active);
|
||||
assert_eq!(single.runtime.providers.len(), 1);
|
||||
assert_eq!(single.primary.as_ref().unwrap().id, fixture.providers[0].id);
|
||||
settings.auto_failover_enabled = true;
|
||||
let automatic = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(automatic.auto_failover_active);
|
||||
assert_eq!(automatic.runtime.providers.len(), 2);
|
||||
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
|
||||
stop(&fixture);
|
||||
let stopped = get_status(fixture.scope()).unwrap();
|
||||
assert!(!stopped.running && !stopped.takeover_active);
|
||||
assert!(stopped.settings.auto_failover_enabled);
|
||||
assert_eq!(stopped.settings.provider_ids.len(), 2);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["mcp_servers"]["fixture"]["command"].as_str(),
|
||||
Some("fixture-tool")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[0].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_auto_switches_to_p1_even_if_current_is_elsewhere() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec![
|
||||
fixture.providers[2].id.clone(),
|
||||
fixture.providers[0].id.clone(),
|
||||
];
|
||||
let status = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(status.auto_failover_active);
|
||||
assert_eq!(status.primary.as_ref().unwrap().id, fixture.providers[2].id);
|
||||
assert_eq!(status.runtime.providers[0].id, fixture.providers[2].id);
|
||||
assert_eq!(status.settings.provider_ids[0], fixture.providers[2].id);
|
||||
stop(&fixture);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[2].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_provider_queue_and_an_empty_running_queue_are_valid() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids.clear();
|
||||
let single = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert_eq!(
|
||||
single.settings.provider_ids,
|
||||
vec![fixture.providers[0].id.clone()]
|
||||
);
|
||||
assert!(single.auto_failover_active);
|
||||
let mut settings = single.settings;
|
||||
settings.provider_ids.clear();
|
||||
let empty = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(empty.running && empty.auto_failover_active);
|
||||
assert!(empty.runtime.providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_accepts_different_models_and_can_be_prepared_while_stopped() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].model = "another-model".into();
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(
|
||||
status
|
||||
.providers
|
||||
.iter()
|
||||
.find(|p| p.id == fixture.providers[1].id)
|
||||
.unwrap()
|
||||
.eligible
|
||||
);
|
||||
let mut settings = fixture.settings();
|
||||
settings.router_enabled = false;
|
||||
settings.takeover_enabled = false;
|
||||
settings.auto_failover_enabled = false;
|
||||
let saved = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(!saved.running);
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
assert_eq!(saved.settings.provider_ids.len(), 2);
|
||||
fixture.enable();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_and_scope_validation_have_no_live_side_effects() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.listen_port = 1000;
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.provider_ids.push(invalid.provider_ids[0].clone());
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.provider_ids = vec!["official:openai-official".into()];
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
let mut invalid = fixture.settings();
|
||||
invalid.takeover_enabled = false;
|
||||
assert!(save_settings(fixture.scope(), invalid).is_err());
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_bind_and_missing_p1_leave_configuration_and_settings_unchanged() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let blocker =
|
||||
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, fixture.port)).unwrap();
|
||||
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
drop(blocker);
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec!["missing-p1".into()];
|
||||
assert!(save_settings(fixture.scope(), settings).is_err());
|
||||
assert_eq!(fixture.text(), fixture.original);
|
||||
assert!(!get_status(fixture.scope()).unwrap().settings.router_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_and_tuning_updates_keep_the_endpoint_and_update_all_parameters() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let before = fixture.text();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids.reverse();
|
||||
settings.tuning = RoutingTuning {
|
||||
max_retries: 8,
|
||||
streaming_first_byte_timeout: 10,
|
||||
streaming_idle_timeout: 0,
|
||||
non_streaming_timeout: 900,
|
||||
circuit_failure_threshold: 7,
|
||||
circuit_success_threshold: 4,
|
||||
circuit_timeout_seconds: 45,
|
||||
circuit_error_rate_threshold: 0.75,
|
||||
circuit_min_requests: 15,
|
||||
};
|
||||
let status = save_settings(fixture.scope(), settings.clone()).unwrap();
|
||||
assert_eq!(status.settings, settings);
|
||||
assert_eq!(fixture.text(), before);
|
||||
assert_eq!(status.runtime.providers[0].id, fixture.providers[1].id);
|
||||
let mut bad = status.settings;
|
||||
bad.listen_port = free_port();
|
||||
assert!(save_settings(fixture.scope(), bad).is_err());
|
||||
assert_eq!(fixture.text(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_switch_keeps_routing_and_auto_but_official_never_joins_api_queue() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[2].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let switched = get_status(fixture.scope()).unwrap();
|
||||
assert!(switched.running && switched.auto_failover_active);
|
||||
assert_eq!(switched.primary.unwrap().id, fixture.providers[2].id);
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::official_profiles::switch_official_profile_inner(
|
||||
fixture.scope(),
|
||||
crate::providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.into(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let official = get_status(fixture.scope()).unwrap();
|
||||
assert!(official.running && official.takeover_active);
|
||||
assert!(!official.auto_failover_active);
|
||||
assert!(official.settings.auto_failover_enabled);
|
||||
assert!(official.primary.unwrap().official);
|
||||
assert!(official
|
||||
.runtime
|
||||
.providers
|
||||
.iter()
|
||||
.all(|p| p.id.starts_with("official:")));
|
||||
assert!(official.providers.iter().all(|p| !p.official));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_takeover_preserves_auth_and_can_restore_a_builtin_route() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let original = "model='official-model'\n[mcp_servers.test]\ncommand='tool'\n";
|
||||
fs::write(crate::config_path(&fixture.dir), original).unwrap();
|
||||
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.auto_failover_enabled = false;
|
||||
settings.provider_ids.clear();
|
||||
let status = save_settings(fixture.scope(), settings).unwrap();
|
||||
assert!(status.primary.unwrap().official);
|
||||
assert!(status.takeover_active && !status.auto_failover_active);
|
||||
let doc = parsed(&fixture);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["openai"]["requires_openai_auth"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert!(doc["model_providers"]["openai"]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert!(doc["model_providers"]["openai"]["http_headers"]
|
||||
.get(ROUTE_TOKEN_HEADER)
|
||||
.is_some());
|
||||
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
|
||||
stop(&fixture);
|
||||
assert!(parsed(&fixture).get("model_providers").is_none());
|
||||
assert_eq!(
|
||||
parsed(&fixture)["mcp_servers"]["test"]["command"].as_str(),
|
||||
Some("tool")
|
||||
);
|
||||
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_manual_switch_reattaches_original_route() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let error = with_provider_change::<()>(fixture.scope(), || {
|
||||
Err(CodexxError::Config("expected switch failure".into()))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("expected switch failure"));
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert_eq!(status.primary.unwrap().id, fixture.providers[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keeps_external_edits_and_does_not_leave_local_headers() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let mut doc = parsed(&fixture);
|
||||
doc["model_reasoning_effort"] = value("max");
|
||||
doc["model_providers"]["custom"]["request_max_retries"] = value(9);
|
||||
doc["model_providers"]["custom"]["external_new_setting"] = value("preserved");
|
||||
fs::write(crate::config_path(&fixture.dir), doc.to_string()).unwrap();
|
||||
stop(&fixture);
|
||||
let restored = parsed(&fixture);
|
||||
assert_eq!(restored["model_reasoning_effort"].as_str(), Some("max"));
|
||||
assert_eq!(
|
||||
restored["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(9)
|
||||
);
|
||||
assert_eq!(
|
||||
restored["model_providers"]["custom"]["external_new_setting"].as_str(),
|
||||
Some("preserved")
|
||||
);
|
||||
assert_eq!(
|
||||
restored["model_providers"]["custom"]["http_headers"]["x-fixture"].as_str(),
|
||||
Some("primary-header")
|
||||
);
|
||||
assert!(!fixture.text().contains(ROUTE_GENERATION_HEADER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_provider_change_detaches_without_stopping_the_listener() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let external = fixture
|
||||
.original
|
||||
.replace(
|
||||
&fixture.providers[0].base_url,
|
||||
&fixture.providers[2].base_url,
|
||||
)
|
||||
.replace("fixture-provider-secret-0", "fixture-provider-secret-2");
|
||||
fs::write(crate::config_path(&fixture.dir), &external).unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && !status.takeover_active);
|
||||
assert!(!status.settings.takeover_enabled);
|
||||
assert_eq!(fixture.text(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_restore_keeps_the_listener_alive() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let managed = fixture.text();
|
||||
fs::write(crate::config_path(&fixture.dir), "invalid = [").unwrap();
|
||||
let mut off = fixture.settings();
|
||||
off.router_enabled = false;
|
||||
assert!(save_settings(fixture.scope(), off).is_err());
|
||||
assert!(lock_manager().unwrap().contains_key(&fixture.dir));
|
||||
fs::write(crate::config_path(&fixture.dir), managed).unwrap();
|
||||
stop(&fixture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_update_handoff_resumes_saved_routes_and_reopens_mutation_gate() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let before = fixture.enable();
|
||||
let auth = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
shutdown_all().unwrap();
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
resume_after_failed_update().unwrap();
|
||||
let after = get_status(fixture.scope()).unwrap();
|
||||
assert!(after.running && after.takeover_active);
|
||||
assert_eq!(after.settings, before.settings);
|
||||
assert_eq!(fs::read(crate::auth_path(&fixture.dir)).unwrap(), auth);
|
||||
stop(&fixture);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_update_recovery_reports_busy_port_without_erasing_saved_preferences() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let before = fixture.enable();
|
||||
shutdown_all().unwrap();
|
||||
let occupied =
|
||||
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, fixture.port)).unwrap();
|
||||
assert!(resume_after_failed_update().is_err());
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(!status.running);
|
||||
assert_eq!(status.settings, before.settings);
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
drop(occupied);
|
||||
resume_after_failed_update().unwrap();
|
||||
assert!(get_status(fixture.scope()).unwrap().takeover_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_directory_scopes_resolve_to_the_interactive_runtime_key() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let scope = normalized_path_scope(&fixture.dir);
|
||||
let stored = stored_directories().unwrap();
|
||||
let restored = stored
|
||||
.iter()
|
||||
.find(|dir| normalized_path_scope(dir) == scope)
|
||||
.unwrap();
|
||||
assert_eq!(*restored, directory(fixture.scope()).unwrap());
|
||||
assert!(lock_manager().unwrap().contains_key(restored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_restores_and_rejects_queued_enabling_then_restart_resumes_same_port() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
shutdown_all().unwrap();
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
let record = load_record(&fixture.dir).unwrap();
|
||||
assert!(record.settings.router_enabled && record.settings.takeover_enabled);
|
||||
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
initialize().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.takeover_active);
|
||||
assert_eq!(status.settings.listen_port, fixture.port);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_managed_backups_keep_their_original_provider_after_later_takeovers() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let old = fixture.text();
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[2].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
stop(&fixture);
|
||||
fs::write(crate::config_path(&fixture.dir), old).unwrap();
|
||||
recover_stale_route(fixture.scope()).unwrap();
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[0].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_enabled_backups_settings_migrate_to_full_p1_queue() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let old = serde_json::json!({"settings":{"enabled":true,"providerIds":[fixture.providers[1].id]},"journals":[{"primaryId":fixture.providers[0].id,"providerKey":"custom","originalTable":"name='old'","port":fixture.port,"token":"old-only-test-token"}]});
|
||||
crate::app_db::open()
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO provider_failover(codex_dir,record_json) VALUES(?1,?2)",
|
||||
params![normalized_path_scope(&fixture.dir), old.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
let migrated = load_record(&fixture.dir).unwrap();
|
||||
assert_eq!(migrated.version, 2);
|
||||
assert!(
|
||||
migrated.settings.router_enabled
|
||||
&& migrated.settings.takeover_enabled
|
||||
&& migrated.settings.auto_failover_enabled
|
||||
);
|
||||
assert_eq!(
|
||||
migrated.settings.provider_ids,
|
||||
vec![
|
||||
fixture.providers[0].id.clone(),
|
||||
fixture.providers[1].id.clone()
|
||||
]
|
||||
);
|
||||
assert_eq!(migrated.settings.tuning, RoutingTuning::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_never_serializes_provider_credentials_or_local_tokens() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let status = fixture.enable();
|
||||
let text = serde_json::to_string(&status).unwrap();
|
||||
assert!(!text.contains("fixture-provider-secret"));
|
||||
let record = load_record(&fixture.dir).unwrap();
|
||||
assert!(!text.contains(&record.journals.last().unwrap().token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_queue_member_keeps_the_rest_and_listener() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
crate::providers::delete_provider_inner(&fixture.providers[1].id).unwrap();
|
||||
refresh_saved_routes().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert_eq!(status.runtime.providers.len(), 1);
|
||||
assert_eq!(status.runtime.providers[0].id, fixture.providers[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_can_run_without_a_configured_provider() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fs::remove_file(crate::config_path(&fixture.dir)).unwrap();
|
||||
let status = save_settings(
|
||||
fixture.scope(),
|
||||
FailoverSettings {
|
||||
router_enabled: true,
|
||||
listen_port: fixture.port,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(status.running && !status.takeover_active);
|
||||
assert!(!crate::config_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_fallback_updates_logical_provider_and_preserves_old_backup_identity() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.providers[1].model = "different-default".into();
|
||||
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
|
||||
fixture.enable();
|
||||
let earlier_backup = fixture.text();
|
||||
let mut runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
let notice = SelectionNotice {
|
||||
dir: fixture.dir.clone(),
|
||||
instance_id: runtime.instance_id,
|
||||
token: runtime.token.clone(),
|
||||
event: ProxySelectionEvent {
|
||||
provider_id: fixture.providers[1].id.clone(),
|
||||
revision: runtime.proxy.revision(),
|
||||
},
|
||||
};
|
||||
record_selection(notice, &mut runtimes).unwrap();
|
||||
drop(runtimes);
|
||||
let selected = get_status(fixture.scope()).unwrap();
|
||||
assert_eq!(selected.primary.unwrap().id, fixture.providers[1].id);
|
||||
assert_eq!(parsed(&fixture)["model"].as_str(), Some("same-model"));
|
||||
let direct = direct_document(&fixture.dir, &parsed(&fixture)).unwrap();
|
||||
assert_eq!(
|
||||
direct["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("Provider 1")
|
||||
);
|
||||
assert_eq!(
|
||||
direct["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
|
||||
Some("fixture-provider-secret-1")
|
||||
);
|
||||
stop(&fixture);
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[1].base_url.as_str())
|
||||
);
|
||||
fs::write(crate::config_path(&fixture.dir), earlier_backup).unwrap();
|
||||
recover_stale_route(fixture.scope()).unwrap();
|
||||
assert_eq!(
|
||||
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some(fixture.providers[0].base_url.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_success_cannot_override_a_manual_switch_or_a_changed_queue() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let notice = {
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
SelectionNotice {
|
||||
dir: fixture.dir.clone(),
|
||||
instance_id: runtime.instance_id,
|
||||
token: runtime.token.clone(),
|
||||
event: ProxySelectionEvent {
|
||||
provider_id: fixture.providers[1].id.clone(),
|
||||
revision: runtime.proxy.revision(),
|
||||
},
|
||||
}
|
||||
};
|
||||
with_provider_change(fixture.scope(), || {
|
||||
crate::providers::activate_saved_provider_inner(
|
||||
fixture.scope(),
|
||||
fixture.providers[2].id.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
record_selection(notice, &mut lock_manager().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
get_status(fixture.scope()).unwrap().primary.unwrap().id,
|
||||
fixture.providers[2].id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_success_from_a_stopped_listener_cannot_change_the_restarted_route() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let notice = {
|
||||
let runtimes = lock_manager().unwrap();
|
||||
let runtime = runtimes.get(&fixture.dir).unwrap();
|
||||
SelectionNotice {
|
||||
dir: fixture.dir.clone(),
|
||||
instance_id: runtime.instance_id,
|
||||
token: runtime.token.clone(),
|
||||
event: ProxySelectionEvent {
|
||||
provider_id: fixture.providers[1].id.clone(),
|
||||
revision: runtime.proxy.revision(),
|
||||
},
|
||||
}
|
||||
};
|
||||
stop(&fixture);
|
||||
fixture.enable();
|
||||
let mut runtimes = lock_manager().unwrap();
|
||||
let restarted = runtimes.get(&fixture.dir).unwrap();
|
||||
assert_eq!(notice.token, restarted.token);
|
||||
assert_eq!(notice.event.revision, restarted.proxy.revision());
|
||||
assert_ne!(notice.instance_id, restarted.instance_id);
|
||||
record_selection(notice, &mut runtimes).unwrap();
|
||||
drop(runtimes);
|
||||
assert_eq!(
|
||||
get_status(fixture.scope()).unwrap().primary.unwrap().id,
|
||||
fixture.providers[0].id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_journal_write_failure_after_p1_rolls_back_files_and_common_recovery_marker() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let target = &fixture.providers[2].id;
|
||||
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_p1_journal BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec![target.clone()];
|
||||
let result = save_settings(fixture.scope(), settings);
|
||||
crate::app_db::open()
|
||||
.unwrap()
|
||||
.execute_batch("DROP TRIGGER reject_p1_journal")
|
||||
.unwrap();
|
||||
assert!(result.is_err());
|
||||
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
assert_eq!(before.config, after.config);
|
||||
assert_eq!(before.auth, after.auth);
|
||||
assert_eq!(before.selected, after.selected);
|
||||
assert_eq!(before.common_handled, after.common_handled);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn p1_journal_failure_restores_official_endpoint_before_oauth() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut fixture = Fixture::new();
|
||||
fs::write(crate::config_path(&fixture.dir), "model='official-model'\n").unwrap();
|
||||
fs::write(crate::auth_path(&fixture.dir),r#"{"auth_mode":"chatgpt","tokens":{"access_token":"fixture-oauth","account_id":"fixture-account"}}"#).unwrap();
|
||||
fixture.providers[2].requires_openai_auth = true;
|
||||
fixture.providers[2] =
|
||||
crate::providers::save_provider_inner(fixture.providers[2].clone()).unwrap();
|
||||
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let mut settings = fixture.settings();
|
||||
settings.provider_ids = vec![fixture.providers[2].id.clone()];
|
||||
// Verify the prepared direct route really reads the global auth file. This
|
||||
// is the interval a failed journal write must undo before publishing OAuth.
|
||||
switch_to_p1(&fixture.dir, &mut settings).unwrap();
|
||||
let prepared = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
let doc = parsed(&fixture);
|
||||
let key = doc["model_provider"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
doc["model_providers"][key]["requires_openai_auth"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert!(doc["model_providers"][key]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
crate::providers::replacement_write_order(
|
||||
prepared.config.as_deref(),
|
||||
before.config.as_deref()
|
||||
),
|
||||
crate::providers::LiveWriteOrder::ConfigFirst
|
||||
);
|
||||
before.restore(&fixture.dir, &prepared).unwrap();
|
||||
let target = &fixture.providers[2].id;
|
||||
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_official_p1 BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
|
||||
let result = save_settings(fixture.scope(), settings);
|
||||
crate::app_db::open()
|
||||
.unwrap()
|
||||
.execute_batch("DROP TRIGGER reject_official_p1")
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("fixture-journal-failure"));
|
||||
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
|
||||
assert_eq!(before.config, after.config);
|
||||
assert_eq!(before.auth, after.auth);
|
||||
assert_eq!(before.selected, after.selected);
|
||||
assert_eq!(before.common_handled, after.common_handled);
|
||||
assert!(!get_status(fixture.scope()).unwrap().running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_persisted_settings_restore_direct_without_restarting_listener() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
|
||||
runtime.proxy.shutdown();
|
||||
let mut record = load_record(&fixture.dir).unwrap();
|
||||
record.settings.tuning.max_retries = 11;
|
||||
save_record(&fixture.dir, &record).unwrap();
|
||||
initialize().unwrap();
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(!status.running && !status.takeover_active);
|
||||
assert!(!fixture.text().contains("http://127.0.0.1:"));
|
||||
assert!(status.message.unwrap().contains("路由设置需要检查"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_status_write_failure_keeps_the_attached_listener_alive() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.enable();
|
||||
shutdown_all().unwrap();
|
||||
let conn = crate::app_db::open().unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER reject_redundant_status BEFORE UPDATE ON provider_failover WHEN NEW.record_json=OLD.record_json BEGIN SELECT RAISE(ABORT,'fixture-status-failure'); END;").unwrap();
|
||||
SHUTTING_DOWN.store(false, Ordering::Release);
|
||||
let result = initialize();
|
||||
conn.execute_batch("DROP TRIGGER reject_redundant_status")
|
||||
.unwrap();
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("fixture-status-failure"));
|
||||
let status = get_status(fixture.scope()).unwrap();
|
||||
assert!(status.running && status.takeover_active);
|
||||
assert!(fixture.text().contains("http://127.0.0.1:"));
|
||||
assert!(std::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, fixture.port)).is_ok());
|
||||
stop(&fixture);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
use super::*;
|
||||
|
||||
struct OwnedRouteFixture {
|
||||
dir: PathBuf,
|
||||
raw: String,
|
||||
local_token: String,
|
||||
}
|
||||
|
||||
impl OwnedRouteFixture {
|
||||
fn new() -> Self {
|
||||
let unique = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap()
|
||||
);
|
||||
let dir = std::env::temp_dir().join(format!("codex-x-failover-integration-{unique}"));
|
||||
fs::create_dir_all(&dir).expect("create isolated Codex directory");
|
||||
let local_token = format!("local-token-{unique}");
|
||||
let original = r#"name = "Primary"
|
||||
base_url = "https://primary.example.test/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "real-test-key"
|
||||
supports_websockets = true
|
||||
"#;
|
||||
let raw = format!(
|
||||
r#"model_provider = "custom"
|
||||
model = "test-model"
|
||||
model_reasoning_effort = "high"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Primary"
|
||||
base_url = "http://127.0.0.1:45555/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "{local_token}"
|
||||
supports_websockets = false
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
|
||||
[mcp_servers.example]
|
||||
command = "local-fixture-command"
|
||||
"#
|
||||
);
|
||||
fs::write(config_path(&dir), &raw).expect("write synthetic local route");
|
||||
let record = json!({
|
||||
"settings": {"enabled":false,"providerIds":[]},
|
||||
"journals": [{"primaryId":"fixture-primary", "providerKey":"custom",
|
||||
"originalTable":original, "port":45555, "token":local_token}],
|
||||
});
|
||||
app_db::open()
|
||||
.expect("open isolated test store")
|
||||
.execute(
|
||||
"INSERT INTO provider_failover (codex_dir, record_json) VALUES (?1, ?2)",
|
||||
params![paths::normalized_path_scope(&dir), record.to_string()],
|
||||
)
|
||||
.expect("seed owned route journal");
|
||||
Self {
|
||||
dir,
|
||||
raw,
|
||||
local_token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedRouteFixture {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(conn) = app_db::open() {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM provider_failover WHERE codex_dir = ?1",
|
||||
[paths::normalized_path_scope(&self.dir)],
|
||||
);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_route_state_detection_and_drafts_use_original_provider_without_writes() {
|
||||
let fixture = OwnedRouteFixture::new();
|
||||
let mut detected = providers::detected_live_custom_provider(&fixture.dir)
|
||||
.expect("detect logical provider")
|
||||
.expect("third-party provider");
|
||||
assert_eq!(detected.base_url, "https://primary.example.test/v1");
|
||||
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
|
||||
assert!(!detected
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains(&fixture.local_token));
|
||||
detected.toml_config = None;
|
||||
let draft = build_provider_toml_draft_inner(detected, Some(fixture.dir.display().to_string()))
|
||||
.expect("inherit direct route in editor draft");
|
||||
assert!(!draft.contains("127.0.0.1"));
|
||||
assert!(!draft.contains(&fixture.local_token));
|
||||
assert!(draft.contains("local-fixture-command"));
|
||||
let state =
|
||||
build_state_after_migration(fixture.dir.clone()).expect("read logical application state");
|
||||
let state_json = serde_json::to_value(&state).expect("serialize public state");
|
||||
assert_eq!(
|
||||
state_json["providers"][0]["baseUrl"],
|
||||
"https://primary.example.test/v1"
|
||||
);
|
||||
assert_eq!(state.model.as_deref(), Some("test-model"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
assert!(!auth_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_managed_toml_submission_restores_owned_route_but_preserves_new_api_key() {
|
||||
let fixture = OwnedRouteFixture::new();
|
||||
for (input_key, expected_key) in [
|
||||
(
|
||||
Some(fixture.local_token.clone()),
|
||||
Some("real-test-key".to_string()),
|
||||
),
|
||||
(
|
||||
Some("new-user-key".to_string()),
|
||||
Some("new-user-key".to_string()),
|
||||
),
|
||||
(
|
||||
Some(format!(" {} ", fixture.local_token)),
|
||||
Some("real-test-key".to_string()),
|
||||
),
|
||||
(None, None),
|
||||
] {
|
||||
let input = direct_provider_toml_input(ProviderTomlInput {
|
||||
config_dir: Some(fixture.dir.display().to_string()),
|
||||
config_text: fixture.raw.replace("\"high\"", "\"low\""),
|
||||
api_key: input_key,
|
||||
})
|
||||
.expect("unwrap stale editor payload");
|
||||
assert_eq!(input.api_key, expected_key);
|
||||
let doc = input
|
||||
.config_text
|
||||
.parse::<DocumentMut>()
|
||||
.expect("parse direct payload");
|
||||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("low"));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://primary.example.test/v1")
|
||||
);
|
||||
assert!(!input.config_text.contains(&fixture.local_token));
|
||||
assert!(input.config_text.contains("local-fixture-command"));
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
assert!(!auth_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_unwrap_handles_multiple_owned_tables_before_detecting_selected_provider() {
|
||||
let mut fixture = OwnedRouteFixture::new();
|
||||
let second_original = "name = \"Inactive\"\nbase_url = \"https://secondary.example.test/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-test-key\"\n";
|
||||
fixture.raw.push_str("\n[model_providers.inactive]\nname = \"Inactive\"\nbase_url = \"http://127.0.0.1:45556/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-local-token\"\n");
|
||||
fs::write(config_path(&fixture.dir), &fixture.raw).expect("write second local table");
|
||||
let conn = app_db::open().expect("read fixture store");
|
||||
let scope = paths::normalized_path_scope(&fixture.dir);
|
||||
let text: String = conn
|
||||
.query_row(
|
||||
"SELECT record_json FROM provider_failover WHERE codex_dir = ?1",
|
||||
[&scope],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read recovery journal");
|
||||
let mut record: Value = serde_json::from_str(&text).expect("parse recovery journal");
|
||||
record["journals"].as_array_mut().unwrap().push(json!({
|
||||
"primaryId":"inactive", "providerKey":"inactive", "originalTable":second_original,
|
||||
"port":45556, "token":"secondary-local-token"
|
||||
}));
|
||||
conn.execute(
|
||||
"UPDATE provider_failover SET record_json = ?1 WHERE codex_dir = ?2",
|
||||
params![record.to_string(), scope],
|
||||
)
|
||||
.expect("append recovery identity");
|
||||
drop(conn);
|
||||
let detected = providers::detected_live_custom_provider(&fixture.dir)
|
||||
.expect("detect selected provider")
|
||||
.expect("selected third-party provider");
|
||||
assert_eq!(detected.base_url, "https://primary.example.test/v1");
|
||||
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
|
||||
assert!(!detected.toml_config.unwrap().contains("127.0.0.1"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_owned_route_keeps_official_status_quota_refresh_and_snapshot_credentials_scoped() {
|
||||
let _guard = app_db::test_db_guard();
|
||||
let mut fixture = OwnedRouteFixture::new();
|
||||
let original = "name = 'OpenAI'\nwire_api = 'responses'\nrequires_openai_auth = true\nsupports_websockets = true\n";
|
||||
fixture.raw = format!("model_provider='custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nwire_api='responses'\nrequires_openai_auth=true\nsupports_websockets=false\nbase_url='http://127.0.0.1:45555/v1'\nhttp_headers={{'x-codex-x-route-token'='{}'}}\n[mcp_servers.keep]\ncommand='keep-native-mcp'\n", fixture.local_token);
|
||||
fs::write(config_path(&fixture.dir), &fixture.raw).unwrap();
|
||||
let record = json!({
|
||||
"version":2,
|
||||
"settings":{"routerEnabled":false,"takeoverEnabled":false,"autoFailoverEnabled":false,"providerIds":[]},
|
||||
"journals":[{"primaryId":"official:openai-official","providerKey":"custom","originalTable":original,
|
||||
"listenAddress":"127.0.0.1","port":45555,"token":fixture.local_token,"official":true,"tableExisted":true,"providersExisted":true}]
|
||||
});
|
||||
app_db::open()
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE provider_failover SET record_json=?1 WHERE codex_dir=?2",
|
||||
params![
|
||||
record.to_string(),
|
||||
paths::normalized_path_scope(&fixture.dir)
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let auth = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-current-access","refresh_token":"native-refresh","account_id":"native-account"}});
|
||||
fs::write(auth_path(&fixture.dir), auth.to_string()).unwrap();
|
||||
let config_before = fs::read(config_path(&fixture.dir)).unwrap();
|
||||
let auth_before = fs::read(auth_path(&fixture.dir)).unwrap();
|
||||
let state = build_state_after_migration(fixture.dir.clone()).unwrap();
|
||||
assert!(state.is_official_provider);
|
||||
assert_eq!(
|
||||
state.active_official_profile_id.as_deref(),
|
||||
Some("openai-official")
|
||||
);
|
||||
assert!(providers::detected_live_custom_provider(&fixture.dir)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let profiles = list_official_profiles_inner(Some(fixture.dir.display().to_string())).unwrap();
|
||||
assert!(profiles[0].is_current);
|
||||
assert!(profiles[0].can_query_quota);
|
||||
let credentials = providers::official_profiles::official_profile_quota_credentials(
|
||||
&fixture.dir,
|
||||
"openai-official",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(credentials.access_token, "native-current-access");
|
||||
let route = failover::native_official::route_for_current(&fixture.dir)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(route.api_key.is_none());
|
||||
let spec = route.official.unwrap();
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-current-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_ok());
|
||||
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
|
||||
assert_eq!(fs::read(auth_path(&fixture.dir)).unwrap(), auth_before);
|
||||
let refreshed = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-refreshed-access","refresh_token":"native-refreshed-refresh","account_id":"native-account"}});
|
||||
fs::write(auth_path(&fixture.dir), refreshed.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
providers::official_profiles::official_profile_quota_credentials(
|
||||
&fixture.dir,
|
||||
"openai-official"
|
||||
)
|
||||
.unwrap()
|
||||
.access_token,
|
||||
"native-refreshed-access"
|
||||
);
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-current-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_err());
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-refreshed-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_ok());
|
||||
assert!(providers::capture_live_chatgpt_config(&fixture.dir).unwrap());
|
||||
let snapshot_path = providers::official_snapshot_path_for_test(&fixture.dir).unwrap();
|
||||
let snapshot: Value = serde_json::from_slice(&fs::read(&snapshot_path).unwrap()).unwrap();
|
||||
let saved_config = snapshot["config"].as_str().unwrap();
|
||||
assert!(!saved_config.contains("127.0.0.1"));
|
||||
assert!(!saved_config.contains("x-codex-x-route-token"));
|
||||
assert!(!saved_config.contains(&fixture.local_token));
|
||||
assert!(saved_config.contains("keep-native-mcp"));
|
||||
assert_eq!(
|
||||
snapshot["auth"]["tokens"]["access_token"],
|
||||
"native-refreshed-access"
|
||||
);
|
||||
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&fs::read(auth_path(&fixture.dir)).unwrap()).unwrap(),
|
||||
refreshed
|
||||
);
|
||||
fs::remove_file(snapshot_path).unwrap();
|
||||
}
|
||||
+343
-103
@@ -14,13 +14,16 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
mod app_db;
|
||||
mod app_update;
|
||||
mod backups;
|
||||
mod ccswitch;
|
||||
mod config_health;
|
||||
mod config_migration;
|
||||
mod constants;
|
||||
mod context_config;
|
||||
mod desktop_lifecycle;
|
||||
mod error;
|
||||
mod failover;
|
||||
mod file_io;
|
||||
mod live_config;
|
||||
mod paths;
|
||||
@@ -33,6 +36,7 @@ mod skills_mcp;
|
||||
mod sqlite_utils;
|
||||
mod state;
|
||||
mod toml_utils;
|
||||
mod transfers;
|
||||
mod updates;
|
||||
mod usage;
|
||||
|
||||
@@ -92,15 +96,14 @@ use providers::{
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
build_provider_toml_draft_inner, clear_active_provider_on_connection,
|
||||
delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
build_provider_toml_draft_inner, build_provider_toml_draft_with_origin_inner,
|
||||
clear_active_provider_on_connection, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
import_ccswitch_codex_providers_inner, list_saved_providers_inner, open_store,
|
||||
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_inner,
|
||||
save_provider_toml_config_inner, switch_provider_inner, test_provider_connection_inner,
|
||||
ImportResult, OfficialAuthCandidate, OfficialConfigDraft, OfficialConfigInput,
|
||||
ProviderConnectionResult, ProviderInput, ProviderModelsResult, ProviderTomlInput,
|
||||
SavedProvider,
|
||||
ImportResult, OfficialAuthCandidate, OfficialConfigInput, ProviderConnectionResult,
|
||||
ProviderInput, ProviderModelsResult, ProviderTomlInput, SavedProvider,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use sessions::{
|
||||
@@ -235,6 +238,12 @@ pub(crate) fn sanitize_id(input: &str) -> String {
|
||||
}
|
||||
|
||||
fn default_codex_dir() -> Result<PathBuf> {
|
||||
// Explicit app-only override for isolated native validation and portable setups.
|
||||
if let Ok(value) = std::env::var("CODEXX_CONFIG_DIR") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
if let Ok(value) = std::env::var("CODEX_HOME") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
@@ -533,6 +542,38 @@ async fn get_startup_diagnostics(config_dir: Option<String>) -> Result<StartupDi
|
||||
.map_err(|e| CodexxError::Config(format!("启动检测失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_codex_config(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<config_health::ConfigHealthReport> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::check_codex_config_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("检查配置失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_codex_config(
|
||||
config_dir: Option<String>,
|
||||
expected_fingerprint: String,
|
||||
) -> Result<config_health::ConfigHealthRepairResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::repair_codex_config_inner(config_dir, expected_fingerprint)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("修复配置失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_codex_config_file(config_dir: Option<String>) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::open_codex_config_file_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("打开配置文件失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_session_sync_status(
|
||||
config_dir: Option<String>,
|
||||
@@ -575,9 +616,15 @@ async fn read_ccswitch_official_auth(
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_ccswitch_codex_providers(db_path: Option<String>) -> Result<ImportResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || import_ccswitch_codex_providers_inner(db_path))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = import_ccswitch_codex_providers_inner(db_path)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已导入,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
|
||||
}
|
||||
|
||||
fn get_about_info_inner(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
@@ -897,6 +944,25 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_order(config_dir: Option<String>) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || providers::get_provider_order_inner(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_order(
|
||||
config_dir: Option<String>,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::save_provider_order_inner(config_dir, order)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
enum ActiveProviderSelectionUpdate {
|
||||
Set(String),
|
||||
ClearIfOfficial,
|
||||
@@ -945,20 +1011,38 @@ fn finish_provider_selection(
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_config_base(config_dir: Option<String>) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::get_provider_config_base_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取供应商通用配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn build_provider_toml_draft(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
new_provider: Option<bool>,
|
||||
) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
if new_provider.unwrap_or(false) {
|
||||
build_provider_toml_draft_with_origin_inner(provider, config_dir, true)
|
||||
} else {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("生成供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
save_provider_inner(provider)
|
||||
let saved = save_provider_inner(provider)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1004,18 +1088,74 @@ async fn get_usage_statistics(
|
||||
.map_err(|error| CodexxError::Config(format!("读取用量统计失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_failover(config_dir: Option<String>) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::get_status(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取自动切换设置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_failover(
|
||||
config_dir: Option<String>,
|
||||
settings: failover::FailoverSettings,
|
||||
) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::save_settings(config_dir, settings))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存自动切换设置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reset_provider_failover_health(
|
||||
config_dir: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::reset_health(config_dir, provider_id))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("重置供应商健康状态失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn activate_saved_provider(
|
||||
config_dir: Option<String>,
|
||||
provider_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("启用供应商失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_active_provider(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
apply_common_config: Option<bool>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = save_active_provider_inner(provider, config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
let result = failover::with_provider_change(config_dir.clone(), || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = if apply_common_config.unwrap_or(false) {
|
||||
providers::save_active_provider_with_common_config_inner(provider, config_dir)?
|
||||
} else {
|
||||
save_active_provider_inner(provider, config_dir)?
|
||||
};
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
@@ -1023,9 +1163,32 @@ async fn save_active_provider(
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_saved_provider(id: String, config_dir: Option<String>) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || delete_saved_provider_inner(id.trim(), config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
delete_saved_provider_inner(id.trim(), config_dir)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已删除,但自动切换状态更新失败:{error}"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_provider_model_catalog(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<providers::model_catalog::ProviderModelCatalogRepairResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let changed = providers::model_catalog::upgrade_owned_model_catalog(&codex_dir)?;
|
||||
Ok(providers::model_catalog::ProviderModelCatalogRepairResult {
|
||||
changed,
|
||||
message: changed.then(|| {
|
||||
"已修复供应商模型菜单的图片输入和思考强度,请重启 Codex 后使用".to_string()
|
||||
}),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("修复供应商模型菜单失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1043,39 +1206,21 @@ fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
|
||||
#[tauri::command]
|
||||
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_config_draft(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Option<OfficialConfigDraft>> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let detail = get_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(Some(OfficialConfigDraft {
|
||||
auth_json: detail.auth_json,
|
||||
config_text: detail.config_text,
|
||||
model: detail.profile.model,
|
||||
source: detail.source,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_official_profiles(config_dir: Option<String>) -> Result<Vec<OfficialProfileSummary>> {
|
||||
tauri::async_runtime::spawn_blocking(move || list_official_profiles_inner(config_dir))
|
||||
@@ -1083,6 +1228,30 @@ async fn list_official_profiles(config_dir: Option<String>) -> Result<Vec<Offici
|
||||
.map_err(|error| CodexxError::Config(format!("读取官方配置列表失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_profile_quota(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<providers::quota::OfficialQuotaSnapshot> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::quota::get_official_profile_quota_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("读取官方账号额度失败,请重试".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_profile_reset_credits(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<providers::quota::OfficialResetCreditsSnapshot> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::quota::get_official_profile_reset_credits_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("读取官方账号重置次数失败,请重试".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_profile(
|
||||
config_dir: Option<String>,
|
||||
@@ -1095,9 +1264,26 @@ async fn get_official_profile(
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_official_profile(input: OfficialProfileInput) -> Result<OfficialProfileActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_official_profile_inner(input))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Saving a separate account is a library edit, not a manual route change.
|
||||
let applies_live = match input.id.as_ref() {
|
||||
Some(id) => {
|
||||
get_official_profile_inner(input.config_dir.clone(), id.clone())?
|
||||
.profile
|
||||
.is_current
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if applies_live {
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
save_official_profile_inner(input)
|
||||
})
|
||||
} else {
|
||||
save_official_profile_inner(input)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1119,7 +1305,9 @@ async fn switch_official_profile(
|
||||
profile_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("切换官方配置失败: {error}")))?
|
||||
@@ -1134,28 +1322,16 @@ async fn delete_official_profile(config_dir: Option<String>, profile_id: String)
|
||||
.map_err(|error| CodexxError::Config(format!("删除官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result =
|
||||
providers::official_profiles::restore_default_official_profile_inner(config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
@@ -1164,16 +1340,18 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
|
||||
#[tauri::command]
|
||||
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -1352,18 +1530,41 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
|
||||
.map_err(|e| CodexxError::Config(format!("禁用外部提示词失败: {e}")))?
|
||||
}
|
||||
|
||||
fn direct_provider_toml_input(mut input: ProviderTomlInput) -> Result<ProviderTomlInput> {
|
||||
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
|
||||
let doc = parse_toml_document(&config_path(&codex_dir), &input.config_text)?;
|
||||
let direct = failover::direct_document(&codex_dir, &doc)?;
|
||||
if direct.to_string() != doc.to_string() {
|
||||
let old_id = string_value(&doc, "model_provider");
|
||||
let old_token = providers::experimental_bearer_token_from_doc(&doc, old_id.as_deref());
|
||||
// Only replace the known local credential echoed back by the editor.
|
||||
// A newly entered key belongs to the user's edit and must be retained.
|
||||
if old_token.is_some() && input.api_key.as_deref().map(str::trim) == old_token.as_deref() {
|
||||
let direct_id = string_value(&direct, "model_provider");
|
||||
input.api_key =
|
||||
providers::experimental_bearer_token_from_doc(&direct, direct_id.as_deref());
|
||||
}
|
||||
input.config_text = direct.to_string();
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_toml_config(
|
||||
input: ProviderTomlInput,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let input = direct_provider_toml_input(input)?;
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
),
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1393,13 +1594,16 @@ async fn fetch_provider_models(
|
||||
#[tauri::command]
|
||||
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
),
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1490,11 +1694,14 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
|
||||
let backup_config = match backup_config {
|
||||
Some(bytes) => {
|
||||
let text = text_from_snapshot(&backup_cfg, Some(&bytes))?;
|
||||
Some(
|
||||
migrated_legacy_prompt_config_text(&cfg, &text)?
|
||||
.unwrap_or(text)
|
||||
.into_bytes(),
|
||||
)
|
||||
let text = migrated_legacy_prompt_config_text(&cfg, &text)?.unwrap_or(text);
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let direct = failover::direct_document(&codex_dir, &doc)?;
|
||||
Some(if direct.to_string() == doc.to_string() {
|
||||
text.into_bytes()
|
||||
} else {
|
||||
direct.to_string().into_bytes()
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -1534,9 +1741,16 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_backup(config_dir: Option<String>, backup_id: String) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_backup_inner(config_dir, backup_id))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let mut result = restore_backup_inner(config_dir.clone(), backup_id)?;
|
||||
failover::recover_stale_route(config_dir)?;
|
||||
result.state = build_state_after_migration(PathBuf::from(&result.state.codex_dir))?;
|
||||
Ok(result)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1585,10 +1799,19 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
desktop_lifecycle::setup_system_tray(app)?;
|
||||
failover::attach_app_handle(app.handle().clone());
|
||||
if let Err(error) = failover::initialize() {
|
||||
desktop_lifecycle::report_failover_lifecycle_error(
|
||||
app.handle(),
|
||||
"自动切换暂时无法启动",
|
||||
&error.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_update::install_app_update,
|
||||
get_about_info,
|
||||
restart_codex_desktop,
|
||||
check_app_update,
|
||||
@@ -1599,8 +1822,14 @@ pub fn run() {
|
||||
toggle_codex_mcp,
|
||||
save_skills_mcp_note,
|
||||
install_skill_zip,
|
||||
transfers::import_skills_mcp_archive,
|
||||
transfers::export_skills_mcp_archive,
|
||||
transfers::export_codex_sessions,
|
||||
check_skill_updates,
|
||||
get_startup_diagnostics,
|
||||
check_codex_config,
|
||||
repair_codex_config,
|
||||
open_codex_config_file,
|
||||
get_session_sync_status,
|
||||
sync_sessions_provider,
|
||||
delete_codex_sessions,
|
||||
@@ -1616,23 +1845,31 @@ pub fn run() {
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
list_saved_providers,
|
||||
get_provider_order,
|
||||
save_provider_order,
|
||||
get_provider_config_base,
|
||||
build_provider_toml_draft,
|
||||
save_provider,
|
||||
duplicate_provider,
|
||||
update_codex_context_window,
|
||||
get_usage_statistics,
|
||||
get_provider_failover,
|
||||
save_provider_failover,
|
||||
reset_provider_failover_health,
|
||||
save_active_provider,
|
||||
activate_saved_provider,
|
||||
delete_saved_provider,
|
||||
get_codex_state,
|
||||
repair_provider_model_catalog,
|
||||
switch_official_provider,
|
||||
get_official_config_draft,
|
||||
list_official_profiles,
|
||||
get_official_profile,
|
||||
get_official_profile_quota,
|
||||
get_official_profile_reset_credits,
|
||||
save_official_profile,
|
||||
duplicate_official_profile,
|
||||
switch_official_profile,
|
||||
delete_official_profile,
|
||||
restore_official_provider,
|
||||
reset_official_provider,
|
||||
save_official_config,
|
||||
enable_instruction,
|
||||
@@ -1655,3 +1892,6 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod failover_integration_tests;
|
||||
|
||||
@@ -315,6 +315,7 @@ pub(crate) fn build_ccswitch_codex_provider(
|
||||
toml_config: Some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
model_mappings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
pub(crate) mod model_catalog;
|
||||
mod official_auth;
|
||||
pub(crate) mod official_profiles;
|
||||
pub(crate) mod quota;
|
||||
mod selection;
|
||||
mod store;
|
||||
pub(crate) mod transport;
|
||||
|
||||
use crate::error::Result;
|
||||
use rusqlite::Connection;
|
||||
@@ -24,12 +27,15 @@ pub(crate) use connection::{
|
||||
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
|
||||
ProviderModelsResult,
|
||||
};
|
||||
pub(crate) use live::detected_live_custom_provider;
|
||||
pub(crate) use live::{
|
||||
build_provider_toml_draft_inner, delete_saved_provider_inner, save_active_provider_inner,
|
||||
save_official_config_inner, save_provider_toml_config_inner, switch_provider_inner,
|
||||
OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
activate_saved_provider_inner, build_provider_toml_draft_inner,
|
||||
build_provider_toml_draft_with_origin_inner, delete_saved_provider_inner,
|
||||
get_provider_config_base_inner, save_active_provider_inner,
|
||||
save_active_provider_with_common_config_inner, save_official_config_inner,
|
||||
save_provider_toml_config_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
|
||||
ProviderTomlInput,
|
||||
};
|
||||
pub(crate) use live::{detected_live_custom_provider, replacement_write_order, LiveWriteOrder};
|
||||
#[cfg(test)]
|
||||
pub(crate) use live::{
|
||||
reset_official_provider_inner, restore_official_provider_inner,
|
||||
@@ -40,9 +46,7 @@ pub(crate) use live::{
|
||||
pub(crate) use official_auth::{
|
||||
capture_live_chatgpt_config, get_official_config_draft_inner, official_snapshot_path_for_test,
|
||||
};
|
||||
pub(crate) use official_auth::{
|
||||
document_is_official, official_auth_available, OfficialConfigDraft,
|
||||
};
|
||||
pub(crate) use official_auth::{document_is_official, official_auth_available};
|
||||
pub(crate) use selection::{
|
||||
clear_active_provider_on_connection, clear_provider_selections_on_connection,
|
||||
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
|
||||
@@ -55,11 +59,12 @@ pub(crate) use store::{
|
||||
pub(crate) use store::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
|
||||
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, normalize_saved_provider,
|
||||
get_provider_order_inner, is_placeholder_provider, list_saved_providers_inner,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
matching_saved_provider_ids_for_live_on_connection, normalize_saved_provider,
|
||||
normalize_saved_provider_for_save, provider_template_from_document, reserved_codex_provider_id,
|
||||
rollback_provider_store_inner, save_detected_provider_with_rollback_inner, save_provider_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
save_provider_order_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
|
||||
ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ pub(crate) struct OfficialConfigCandidate {
|
||||
pub(crate) source: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialConfigDraft {
|
||||
@@ -200,7 +201,13 @@ fn live_official_config_text(codex_dir: &Path) -> Result<Option<String>> {
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
Ok(document_is_official(&doc).then_some(text))
|
||||
let direct = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
let text = if direct.to_string() == doc.to_string() {
|
||||
text
|
||||
} else {
|
||||
direct.to_string()
|
||||
};
|
||||
Ok(document_is_official(&direct).then_some(text))
|
||||
}
|
||||
|
||||
fn remove_bearer_tokens(doc: &mut toml_edit::DocumentMut) {
|
||||
@@ -228,7 +235,8 @@ pub(crate) fn build_official_config_text(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut doc = parse_toml_document(&path, &text)?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
let mut doc = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
|
||||
doc["model_provider"] = value("custom");
|
||||
doc.as_table_mut().remove("base_url");
|
||||
@@ -247,6 +255,14 @@ pub(crate) fn build_official_config_text(
|
||||
doc.as_table_mut().remove("model");
|
||||
}
|
||||
|
||||
super::model_catalog::prepare_model_catalog(
|
||||
codex_dir,
|
||||
"official",
|
||||
&[],
|
||||
model.unwrap_or_default(),
|
||||
&mut doc,
|
||||
)?;
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
@@ -274,6 +290,13 @@ pub(crate) fn validate_official_config_text(
|
||||
doc["model"] = value(model);
|
||||
}
|
||||
}
|
||||
super::model_catalog::prepare_model_catalog(
|
||||
codex_dir,
|
||||
"official",
|
||||
&[],
|
||||
model.unwrap_or_default(),
|
||||
&mut doc,
|
||||
)?;
|
||||
let text = doc.to_string();
|
||||
let model = string_value(&doc, "model");
|
||||
Ok((text, model))
|
||||
@@ -286,6 +309,7 @@ pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
let doc = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
Ok(document_is_official(&doc))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::live::{
|
||||
apply_official_config_with_snapshot_locked, persist_detected_live_custom_provider,
|
||||
read_live_file_snapshot, AppliedLiveFiles, LiveAuthAction,
|
||||
apply_official_config_with_snapshot_locked, official_activation_config_text,
|
||||
persist_detected_live_custom_provider, read_live_file_snapshot, AppliedLiveFiles,
|
||||
LiveAuthAction,
|
||||
};
|
||||
use super::official_auth::{
|
||||
build_official_config_text, capture_live_official_config_before_provider_switch,
|
||||
@@ -19,6 +20,7 @@ use crate::live_config::{
|
||||
use crate::paths::normalized_path_scope;
|
||||
use crate::state::{build_state_after_migration, ActionResult};
|
||||
use crate::{now_rfc3339, resolve_codex_dir};
|
||||
use base64::Engine;
|
||||
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -34,6 +36,10 @@ pub(crate) struct OfficialProfileSummary {
|
||||
pub(crate) provider_name: String,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) has_auth: bool,
|
||||
pub(crate) has_owned_auth: bool,
|
||||
pub(crate) email: Option<String>,
|
||||
pub(crate) plan_type: Option<String>,
|
||||
pub(crate) can_query_quota: bool,
|
||||
pub(crate) is_default: bool,
|
||||
pub(crate) is_current: bool,
|
||||
}
|
||||
@@ -67,6 +73,158 @@ pub(crate) struct OfficialProfileActionResult {
|
||||
pub(crate) profile: OfficialProfileSummary,
|
||||
}
|
||||
|
||||
// Native-only credentials: intentionally neither Debug nor Serialize. The
|
||||
// network caller reads these while holding the short live-config lock, then
|
||||
// drops that lock before making a request.
|
||||
pub(crate) struct OfficialProfileQuotaCredentials {
|
||||
pub(crate) access_token: String,
|
||||
pub(crate) account_id: Option<String>,
|
||||
pub(crate) email: Option<String>,
|
||||
}
|
||||
|
||||
fn nonempty_auth_string(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn display_email(value: Option<&Value>) -> Option<String> {
|
||||
nonempty_auth_string(value).filter(|email| {
|
||||
email.len() <= 320
|
||||
&& email.contains('@')
|
||||
&& !email
|
||||
.chars()
|
||||
.any(|character| character.is_control() || character.is_whitespace())
|
||||
})
|
||||
}
|
||||
|
||||
fn jwt_display_claims(token: Option<&Value>) -> Option<Value> {
|
||||
let token = token?.as_str()?;
|
||||
// Decode only a bounded payload. JWT claims are unverified display metadata,
|
||||
// never the source of account IDs, credentials, or authorization decisions.
|
||||
let mut parts = token.split('.');
|
||||
let header = parts.next()?;
|
||||
let payload = parts.next()?;
|
||||
let signature = parts.next()?;
|
||||
if header.is_empty()
|
||||
|| payload.is_empty()
|
||||
|| signature.is_empty()
|
||||
|| parts.next().is_some()
|
||||
|| payload.len() > 32 * 1024
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.ok()?;
|
||||
serde_json::from_slice(&payload).ok()
|
||||
}
|
||||
|
||||
fn display_plan_type(value: Option<&Value>) -> Option<String> {
|
||||
let raw = value?.as_str()?;
|
||||
if raw.len() > 64 || raw.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
let plan = raw.trim();
|
||||
(!plan.is_empty()
|
||||
&& plan
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')))
|
||||
.then(|| plan.to_string())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OfficialAuthDisplayMetadata {
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
}
|
||||
|
||||
fn auth_display_metadata(auth: &Value) -> OfficialAuthDisplayMetadata {
|
||||
let chatgpt_auth = is_chatgpt_auth(auth);
|
||||
let mut metadata = OfficialAuthDisplayMetadata {
|
||||
email: display_email(auth.get("email")),
|
||||
plan_type: chatgpt_auth
|
||||
.then(|| {
|
||||
display_plan_type(auth.get("plan_type"))
|
||||
.or_else(|| display_plan_type(auth.get("chatgpt_plan_type")))
|
||||
})
|
||||
.flatten(),
|
||||
};
|
||||
for token in [
|
||||
auth.pointer("/tokens/id_token"),
|
||||
auth.pointer("/tokens/access_token"),
|
||||
] {
|
||||
if metadata.email.is_some() && (metadata.plan_type.is_some() || !chatgpt_auth) {
|
||||
break;
|
||||
}
|
||||
let Some(claims) = jwt_display_claims(token) else {
|
||||
continue;
|
||||
};
|
||||
metadata.email = metadata.email.or_else(|| {
|
||||
display_email(claims.get("email")).or_else(|| {
|
||||
display_email(claims.pointer("/https:~1~1api.openai.com~1profile/email"))
|
||||
})
|
||||
});
|
||||
if chatgpt_auth && metadata.plan_type.is_none() {
|
||||
// Codex's login/token_data.rs reads this namespaced ID-token claim.
|
||||
// Keep its raw identifier for display; it never grants access.
|
||||
metadata.plan_type = display_plan_type(
|
||||
claims.pointer("/https:~1~1api.openai.com~1auth/chatgpt_plan_type"),
|
||||
);
|
||||
}
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn auth_display_email(auth: &Value) -> Option<String> {
|
||||
auth_display_metadata(auth).email
|
||||
}
|
||||
|
||||
fn quota_access_token(auth: &Value) -> Option<String> {
|
||||
is_chatgpt_auth(auth)
|
||||
.then(|| nonempty_auth_string(auth.pointer("/tokens/access_token")))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn profile_owned_auth(codex_dir: &Path, profile_id: &str) -> Result<Option<Value>> {
|
||||
profile_name(codex_dir, profile_id)?;
|
||||
let is_current = selected_profile_id(codex_dir)? == profile_id
|
||||
&& live_config_is_official(codex_dir)
|
||||
.map_err(|_| CodexxError::Config("无法确认当前官方登录状态,请刷新配置".to_string()))?;
|
||||
if is_current {
|
||||
let text = read_to_string_if_exists(&crate::auth_path(codex_dir))
|
||||
.map_err(|_| CodexxError::Config("无法读取当前官方认证,请重新登录".to_string()))?;
|
||||
// Live logout/refresh owns the current profile. Do not resurrect a stale
|
||||
// snapshot or consult another account's historical recovery credentials.
|
||||
return parse_auth(&text)
|
||||
.map_err(|_| CodexxError::Config("当前官方认证无效,请重新登录".to_string()));
|
||||
}
|
||||
saved_official_profile_candidate(codex_dir, profile_id)
|
||||
.map(|candidate| candidate.and_then(|candidate| candidate.auth))
|
||||
.map_err(|_| {
|
||||
CodexxError::Config("无法读取此官方配置的认证,请重新保存登录信息".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn official_profile_quota_credentials(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<OfficialProfileQuotaCredentials> {
|
||||
let auth = profile_owned_auth(codex_dir, profile_id)?.ok_or_else(|| {
|
||||
CodexxError::Config("此官方配置尚未登录,请先在 Codex 中登录".to_string())
|
||||
})?;
|
||||
let access_token = quota_access_token(&auth).ok_or_else(|| {
|
||||
CodexxError::Config("此配置没有可用的 ChatGPT 登录凭据,无法查询订阅额度".to_string())
|
||||
})?;
|
||||
Ok(OfficialProfileQuotaCredentials {
|
||||
access_token,
|
||||
account_id: nonempty_auth_string(auth.pointer("/tokens/account_id")),
|
||||
email: auth_display_email(&auth),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProfileRecord {
|
||||
id: String,
|
||||
@@ -223,12 +381,21 @@ fn detail(codex_dir: &Path, id: &str) -> Result<OfficialProfileDetail> {
|
||||
.transpose()
|
||||
.map_err(|error| CodexxError::Config(format!("读取官方认证失败: {error}")))?
|
||||
.unwrap_or_default();
|
||||
let owned_auth = profile_owned_auth(codex_dir, id).ok().flatten();
|
||||
let metadata = owned_auth
|
||||
.as_ref()
|
||||
.map(auth_display_metadata)
|
||||
.unwrap_or_default();
|
||||
Ok(OfficialProfileDetail {
|
||||
profile: OfficialProfileSummary {
|
||||
id: id.to_string(),
|
||||
provider_name,
|
||||
model,
|
||||
has_auth: !auth_json.is_empty(),
|
||||
has_owned_auth: owned_auth.is_some(),
|
||||
email: metadata.email,
|
||||
plan_type: metadata.plan_type,
|
||||
can_query_quota: owned_auth.as_ref().and_then(quota_access_token).is_some(),
|
||||
is_default: id == DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
is_current: live_config_is_official(codex_dir)?
|
||||
&& selected_profile_id(codex_dir)? == id,
|
||||
@@ -270,13 +437,16 @@ pub(crate) fn list_official_profiles_inner(
|
||||
let selected = selected_profile_id(&codex_dir)?;
|
||||
let config_path = crate::config_path(&codex_dir);
|
||||
let config = parse_toml_document(&config_path, &read_to_string_if_exists(&config_path)?)?;
|
||||
let config = crate::failover::direct_document(&codex_dir, &config)?;
|
||||
let is_official = document_is_official(&config);
|
||||
let live_model = crate::string_value(&config, "model");
|
||||
let live_has_auth = is_official
|
||||
&& read_to_string_if_exists(&crate::auth_path(&codex_dir))
|
||||
.ok()
|
||||
.and_then(|text| parse_auth(&text).ok().flatten())
|
||||
.is_some();
|
||||
let live_auth = is_official
|
||||
.then(|| {
|
||||
read_to_string_if_exists(&crate::auth_path(&codex_dir))
|
||||
.ok()
|
||||
.and_then(|text| parse_auth(&text).ok().flatten())
|
||||
})
|
||||
.flatten();
|
||||
// Startup only needs metadata and the explicit profile snapshot. Do not
|
||||
// call the editor/recovery candidate path: its legacy fallback scans the
|
||||
// complete backup history and synthesizes full config/auth documents.
|
||||
@@ -287,14 +457,24 @@ pub(crate) fn list_official_profiles_inner(
|
||||
.ok()
|
||||
.flatten();
|
||||
let is_current = is_official && selected == id;
|
||||
let owned_auth = if is_current {
|
||||
live_auth.as_ref()
|
||||
} else {
|
||||
saved.as_ref().and_then(|saved| saved.auth.as_ref())
|
||||
};
|
||||
let metadata = owned_auth.map(auth_display_metadata).unwrap_or_default();
|
||||
OfficialProfileSummary {
|
||||
model: if is_current {
|
||||
live_model.clone()
|
||||
} else {
|
||||
saved.as_ref().and_then(|saved| saved.model.clone())
|
||||
},
|
||||
has_auth: (is_current && live_has_auth)
|
||||
has_auth: (is_current && live_auth.is_some())
|
||||
|| saved.as_ref().is_some_and(|saved| saved.auth.is_some()),
|
||||
has_owned_auth: owned_auth.is_some(),
|
||||
email: metadata.email,
|
||||
plan_type: metadata.plan_type,
|
||||
can_query_quota: owned_auth.and_then(quota_access_token).is_some(),
|
||||
is_default: id == DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
id,
|
||||
provider_name,
|
||||
@@ -666,34 +846,6 @@ pub(crate) fn reset_default_official_profile_inner(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_default_official_profile_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let candidate = if selected_profile_id(&codex_dir)? == DEFAULT_OFFICIAL_PROFILE_ID {
|
||||
official_config_candidate(&codex_dir, true)?
|
||||
} else {
|
||||
saved_official_profile_candidate(&codex_dir, DEFAULT_OFFICIAL_PROFILE_ID)?
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Config("未找到默认官方配置的可信快照,请新建配置后重新登录".to_string())
|
||||
})?;
|
||||
with_mutation(&codex_dir, |mutation| {
|
||||
mutation.snapshot(DEFAULT_OFFICIAL_PROFILE_ID, || {
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
candidate.config_text,
|
||||
candidate.model,
|
||||
candidate.auth,
|
||||
)
|
||||
})?;
|
||||
action(&codex_dir, "已还原默认官方配置".to_string(), None)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn switch_official_profile_inner(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
@@ -717,6 +869,7 @@ fn switch_official_profile_with_before_apply(
|
||||
&target.config_text,
|
||||
target.profile.model.as_deref(),
|
||||
)?;
|
||||
let config = official_activation_config_text(&codex_dir, &config)?;
|
||||
let auth = parse_auth(&target.auth_json)?;
|
||||
with_mutation(&codex_dir, |mutation| {
|
||||
let previous_id = selected_profile_id(&codex_dir)?;
|
||||
@@ -818,6 +971,374 @@ mod tests {
|
||||
json!({"auth_mode":"chatgpt","tokens":{"account_id":format!("account-{label}"),"access_token":format!("access-{label}"),"refresh_token":format!("refresh-{label}")}})
|
||||
}
|
||||
|
||||
fn display_jwt(claims: Value) -> String {
|
||||
format!(
|
||||
"e30.{}.fixture-signature",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string())
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_profile_email_decodes_only_bounded_display_claims() {
|
||||
let mut authentication = auth("email");
|
||||
authentication["tokens"]["id_token"] =
|
||||
json!(display_jwt(json!({"email":"id@example.test"})));
|
||||
authentication["tokens"]["access_token"] = json!(display_jwt(
|
||||
json!({"https://api.openai.com/profile":{"email":"access@example.test"}})
|
||||
));
|
||||
assert_eq!(
|
||||
auth_display_email(&authentication).as_deref(),
|
||||
Some("id@example.test")
|
||||
);
|
||||
authentication["email"] = json!(" direct@example.test ");
|
||||
assert_eq!(
|
||||
auth_display_email(&authentication).as_deref(),
|
||||
Some("direct@example.test")
|
||||
);
|
||||
authentication["email"] = json!("\n");
|
||||
authentication["tokens"]["id_token"] = json!("invalid-token");
|
||||
assert_eq!(
|
||||
auth_display_email(&authentication).as_deref(),
|
||||
Some("access@example.test")
|
||||
);
|
||||
authentication["tokens"]["access_token"] = json!(format!("e30.{}.sig", "A".repeat(32769)));
|
||||
assert!(auth_display_email(&authentication).is_none());
|
||||
authentication["email"] = json!("unsafe\nemail@example.test");
|
||||
assert!(auth_display_email(&authentication).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_profile_plan_type_uses_chatgpt_metadata_with_validated_fallbacks() {
|
||||
let mut authentication = auth("plan");
|
||||
authentication["tokens"]["id_token"] = json!(display_jwt(json!({
|
||||
"email": "id@example.test",
|
||||
"https://api.openai.com/auth": {"chatgpt_plan_type": "pro"}
|
||||
})));
|
||||
authentication["tokens"]["access_token"] = json!(display_jwt(json!({
|
||||
"https://api.openai.com/auth": {"chatgpt_plan_type": "team"}
|
||||
})));
|
||||
let metadata = auth_display_metadata(&authentication);
|
||||
assert_eq!(metadata.email.as_deref(), Some("id@example.test"));
|
||||
assert_eq!(metadata.plan_type.as_deref(), Some("pro"));
|
||||
|
||||
authentication["plan_type"] = json!(" pro_lite ");
|
||||
authentication["chatgpt_plan_type"] = json!("business");
|
||||
assert_eq!(
|
||||
auth_display_metadata(&authentication).plan_type.as_deref(),
|
||||
Some("pro_lite")
|
||||
);
|
||||
authentication["plan_type"] = Value::Null;
|
||||
assert_eq!(
|
||||
auth_display_metadata(&authentication).plan_type.as_deref(),
|
||||
Some("business")
|
||||
);
|
||||
authentication["chatgpt_plan_type"] = Value::Null;
|
||||
authentication["tokens"]["id_token"] = json!("malformed-token");
|
||||
assert_eq!(
|
||||
auth_display_metadata(&authentication).plan_type.as_deref(),
|
||||
Some("team")
|
||||
);
|
||||
|
||||
authentication["plan_type"] = json!("Future_Plan-2");
|
||||
assert_eq!(
|
||||
auth_display_metadata(&authentication).plan_type.as_deref(),
|
||||
Some("Future_Plan-2")
|
||||
);
|
||||
authentication["auth_mode"] = json!("apikey");
|
||||
authentication["OPENAI_API_KEY"] = json!("synthetic-api-key");
|
||||
assert!(auth_display_metadata(&authentication).plan_type.is_none());
|
||||
assert!(auth_display_metadata(&auth("missing-plan"))
|
||||
.plan_type
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_profile_plan_type_rejects_unsafe_and_oversized_metadata() {
|
||||
for value in [
|
||||
json!(""),
|
||||
json!(" \t"),
|
||||
json!("\npro"),
|
||||
json!("pro\n"),
|
||||
json!("pro lite"),
|
||||
json!("pro\u{202e}lite"),
|
||||
json!("<script>"),
|
||||
json!("p".repeat(65)),
|
||||
json!(false),
|
||||
json!(["pro"]),
|
||||
json!({"plan":"pro"}),
|
||||
] {
|
||||
let mut authentication = auth("invalid-plan");
|
||||
authentication["plan_type"] = value.clone();
|
||||
authentication["tokens"]["id_token"] = json!(display_jwt(json!({
|
||||
"https://api.openai.com/auth": {"chatgpt_plan_type": value}
|
||||
})));
|
||||
assert!(auth_display_metadata(&authentication).plan_type.is_none());
|
||||
}
|
||||
let valid_token =
|
||||
display_jwt(json!({"https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}}));
|
||||
for token in [
|
||||
format!("e30.{}.sig", "A".repeat(32769)),
|
||||
format!("{valid_token}.extra"),
|
||||
valid_token.replacen("e30.", ".", 1),
|
||||
valid_token.replace("fixture-signature", ""),
|
||||
display_jwt(json!(["pro"])),
|
||||
display_jwt(
|
||||
json!({"https://api.openai.com/auth":{"chatgpt_plan_type":"pro"},"oversized":"x".repeat(32768)}),
|
||||
),
|
||||
] {
|
||||
let mut authentication = auth("invalid-jwt");
|
||||
authentication["tokens"]["id_token"] = json!(token);
|
||||
assert!(auth_display_metadata(&authentication).plan_type.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_quota_credentials_follow_owned_profiles_live_refresh_and_account_switches() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let dir = prepare_home("quota-profile-isolation");
|
||||
let mut first_auth = auth("a");
|
||||
first_auth["tokens"]["id_token"] = json!(display_jwt(
|
||||
json!({"email":"first@example.test", "account_id":"untrusted-claim", "https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}})
|
||||
));
|
||||
write_json(&auth_path(&dir), &first_auth).unwrap();
|
||||
let first = official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap();
|
||||
assert_eq!(first.access_token, "access-a");
|
||||
assert_eq!(first.account_id.as_deref(), Some("account-a"));
|
||||
assert_eq!(first.email.as_deref(), Some("first@example.test"));
|
||||
let mut second_auth = auth("b");
|
||||
second_auth["email"] = json!("second@example.test");
|
||||
second_auth["tokens"]["id_token"] = json!(display_jwt(
|
||||
json!({"https://api.openai.com/auth":{"chatgpt_plan_type":"team"}})
|
||||
));
|
||||
let second = save(
|
||||
&dir,
|
||||
None,
|
||||
"Second account",
|
||||
Some(config("b")),
|
||||
Some(second_auth.to_string()),
|
||||
);
|
||||
assert_eq!(second.profile.email.as_deref(), Some("second@example.test"));
|
||||
assert_eq!(second.profile.plan_type.as_deref(), Some("team"));
|
||||
assert!(second.profile.can_query_quota);
|
||||
let inactive = official_profile_quota_credentials(&dir, &second.profile.id).unwrap();
|
||||
assert_eq!(inactive.access_token, "access-b");
|
||||
assert_eq!(inactive.account_id.as_deref(), Some("account-b"));
|
||||
switch(&dir, &second.profile.id);
|
||||
let mut refreshed = auth("b-refreshed");
|
||||
refreshed["email"] = json!("refreshed@example.test");
|
||||
refreshed["chatgpt_plan_type"] = json!("prolite");
|
||||
write_json(&auth_path(&dir), &refreshed).unwrap();
|
||||
assert_eq!(
|
||||
official_profile_quota_credentials(&dir, &second.profile.id)
|
||||
.unwrap()
|
||||
.access_token,
|
||||
"access-b-refreshed"
|
||||
);
|
||||
assert_eq!(
|
||||
official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
|
||||
.unwrap()
|
||||
.access_token,
|
||||
"access-a"
|
||||
);
|
||||
let summaries = list_official_profiles_inner(scope(&dir)).unwrap();
|
||||
assert_eq!(
|
||||
summaries
|
||||
.iter()
|
||||
.find(|profile| profile.is_default)
|
||||
.unwrap()
|
||||
.plan_type
|
||||
.as_deref(),
|
||||
Some("pro")
|
||||
);
|
||||
assert_eq!(
|
||||
summaries
|
||||
.iter()
|
||||
.find(|profile| profile.is_current)
|
||||
.unwrap()
|
||||
.plan_type
|
||||
.as_deref(),
|
||||
Some("prolite")
|
||||
);
|
||||
assert_eq!(
|
||||
detail(&dir, &second.profile.id)
|
||||
.unwrap()
|
||||
.profile
|
||||
.plan_type
|
||||
.as_deref(),
|
||||
Some("prolite")
|
||||
);
|
||||
assert_eq!(
|
||||
summaries
|
||||
.iter()
|
||||
.find(|profile| profile.is_current)
|
||||
.unwrap()
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("refreshed@example.test")
|
||||
);
|
||||
let serialized = serde_json::to_string(&summaries).unwrap();
|
||||
assert!(serialized.contains("\"planType\":\"prolite\""));
|
||||
assert!(!serialized.contains("access-b-refreshed"));
|
||||
assert!(!serialized.contains("refresh-b-refreshed"));
|
||||
switch(&dir, DEFAULT_OFFICIAL_PROFILE_ID);
|
||||
assert_eq!(
|
||||
detail(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
|
||||
.unwrap()
|
||||
.profile
|
||||
.plan_type
|
||||
.as_deref(),
|
||||
Some("pro")
|
||||
);
|
||||
assert_eq!(
|
||||
detail(&dir, &second.profile.id)
|
||||
.unwrap()
|
||||
.profile
|
||||
.plan_type
|
||||
.as_deref(),
|
||||
Some("prolite")
|
||||
);
|
||||
assert_eq!(
|
||||
official_profile_quota_credentials(&dir, &second.profile.id)
|
||||
.unwrap()
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("refreshed@example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
|
||||
.unwrap()
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("first@example.test")
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_quota_credentials_never_recover_history_or_a_proxy_login() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let dir = prepare_home("quota-no-history");
|
||||
let mut historical = auth("historical");
|
||||
historical["plan_type"] = json!("pro");
|
||||
write_json(&auth_path(&dir), &historical).unwrap();
|
||||
crate::backups::create_backup(&dir, "quota-ignored-history").unwrap();
|
||||
write_text(&config_path(&dir), "model_provider='custom'\nmodel='proxy-model'\n[model_providers.custom]\nname='Proxy'\nbase_url='https://proxy.example.test/v1'\nrequires_openai_auth=true\n").unwrap();
|
||||
let mut foreign = auth("foreign-live");
|
||||
foreign["email"] = json!("foreign@example.test");
|
||||
foreign["plan_type"] = json!("team");
|
||||
write_json(&auth_path(&dir), &foreign).unwrap();
|
||||
assert!(official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).is_err());
|
||||
let summaries = list_official_profiles_inner(scope(&dir)).unwrap();
|
||||
assert!(summaries[0].email.is_none());
|
||||
assert!(summaries[0].plan_type.is_none());
|
||||
assert!(!summaries[0].can_query_quota);
|
||||
assert!(
|
||||
!official_snapshot_path_for_profile(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
|
||||
.unwrap()
|
||||
.exists()
|
||||
);
|
||||
let named = save(
|
||||
&dir,
|
||||
None,
|
||||
"Missing snapshot",
|
||||
Some(config("b")),
|
||||
Some(auth("b").to_string()),
|
||||
);
|
||||
fs::remove_file(official_snapshot_path_for_profile(&dir, &named.profile.id).unwrap())
|
||||
.unwrap();
|
||||
assert!(official_profile_quota_credentials(&dir, &named.profile.id).is_err());
|
||||
assert!(list_official_profiles_inner(scope(&dir))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|profile| profile.id == named.profile.id)
|
||||
.unwrap()
|
||||
.plan_type
|
||||
.is_none());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_quota_credentials_reject_logout_api_keys_and_missing_access_tokens() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let dir = prepare_home("quota-auth-states");
|
||||
let mut authentication = auth("signed-in");
|
||||
authentication["plan_type"] = json!("pro");
|
||||
write_json(&auth_path(&dir), &authentication).unwrap();
|
||||
save(
|
||||
&dir,
|
||||
Some(DEFAULT_OFFICIAL_PROFILE_ID),
|
||||
"Default",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
fs::remove_file(auth_path(&dir)).unwrap();
|
||||
let summary = list_official_profiles_inner(scope(&dir)).unwrap().remove(0);
|
||||
assert!(
|
||||
summary.has_auth,
|
||||
"legacy saved-auth availability is retained"
|
||||
);
|
||||
assert!(
|
||||
!summary.has_owned_auth,
|
||||
"a logged-out current profile must show the signed-out badge"
|
||||
);
|
||||
assert!(!summary.can_query_quota);
|
||||
assert!(summary.email.is_none());
|
||||
assert!(summary.plan_type.is_none());
|
||||
assert!(detail(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
|
||||
.unwrap()
|
||||
.profile
|
||||
.plan_type
|
||||
.is_none());
|
||||
assert!(official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).is_err());
|
||||
write_text(
|
||||
&auth_path(&dir),
|
||||
"{\"tokens\":\"sensitive-test-marker\",invalid-json}",
|
||||
)
|
||||
.unwrap();
|
||||
let error = official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(!error.contains("sensitive-test-marker"));
|
||||
write_json(&auth_path(&dir), &auth("a")).unwrap();
|
||||
for (name, authentication) in [
|
||||
(
|
||||
"API key",
|
||||
json!({"auth_mode":"apikey", "OPENAI_API_KEY":"sk-test-only"}),
|
||||
),
|
||||
(
|
||||
"Refresh only",
|
||||
json!({"auth_mode":"chatgpt", "tokens":{"refresh_token":"refresh-only"}}),
|
||||
),
|
||||
] {
|
||||
let profile = save(
|
||||
&dir,
|
||||
None,
|
||||
name,
|
||||
Some(config("b")),
|
||||
Some(authentication.to_string()),
|
||||
);
|
||||
assert!(profile.profile.has_auth);
|
||||
assert!(profile.profile.has_owned_auth);
|
||||
assert!(!profile.profile.can_query_quota);
|
||||
assert!(official_profile_quota_credentials(&dir, &profile.profile.id).is_err());
|
||||
}
|
||||
let empty = save(
|
||||
&dir,
|
||||
None,
|
||||
"Not signed in",
|
||||
Some(config("empty")),
|
||||
Some(String::new()),
|
||||
);
|
||||
assert!(!empty.profile.has_auth);
|
||||
assert!(!empty.profile.has_owned_auth);
|
||||
assert!(!empty.profile.can_query_quota);
|
||||
assert!(empty.profile.email.is_none());
|
||||
assert!(empty.profile.plan_type.is_none());
|
||||
assert!(official_profile_quota_credentials(&dir, &empty.profile.id).is_err());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
fn prepare_home(label: &str) -> PathBuf {
|
||||
let dir = test_home(label);
|
||||
write_text(&config_path(&dir), &config("a")).unwrap();
|
||||
@@ -1015,10 +1536,14 @@ mod tests {
|
||||
Some(b.profile.id.as_str())
|
||||
);
|
||||
assert_eq!(live_auth(&dir), auth("b"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
|
||||
config("b").trim_end()
|
||||
);
|
||||
let configured = fs::read_to_string(config_path(&dir))
|
||||
.unwrap()
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
assert_eq!(configured["model"].as_str(), Some("model-b"));
|
||||
assert!(document_is_official(&configured));
|
||||
assert_eq!(configured["approval_policy"].as_str(), Some("never"));
|
||||
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
|
||||
|
||||
let refreshed = auth("b-refreshed");
|
||||
write_json(&auth_path(&dir), &refreshed).unwrap();
|
||||
@@ -1051,10 +1576,14 @@ mod tests {
|
||||
write_json(&auth_path(&dir), &auth("a-refreshed")).unwrap();
|
||||
switch(&dir, &b.profile.id);
|
||||
assert_eq!(live_auth(&dir), refreshed);
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
|
||||
b_config.trim_end()
|
||||
);
|
||||
let configured = fs::read_to_string(config_path(&dir))
|
||||
.unwrap()
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
assert_eq!(configured["model"].as_str(), Some("model-b"));
|
||||
assert!(document_is_official(&configured));
|
||||
assert_eq!(configured["desktop"]["notify"].as_bool(), Some(true));
|
||||
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
|
||||
switch(&dir, DEFAULT_OFFICIAL_PROFILE_ID);
|
||||
assert_eq!(live_auth(&dir), auth("a-refreshed"));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
@@ -1176,7 +1705,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_profiles_default_reset_and_restore_do_not_overwrite_a_named_account() {
|
||||
fn official_profiles_default_reset_does_not_overwrite_a_named_account() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let dir = prepare_home("default-reset");
|
||||
save(
|
||||
@@ -1195,12 +1724,6 @@ mod tests {
|
||||
);
|
||||
switch(&dir, &b.profile.id);
|
||||
write_json(&auth_path(&dir), &auth("b-latest")).unwrap();
|
||||
let restored = restore_default_official_profile_inner(scope(&dir)).unwrap();
|
||||
assert_eq!(
|
||||
restored.state.active_official_profile_id.as_deref(),
|
||||
Some(b.profile.id.as_str())
|
||||
);
|
||||
assert_eq!(live_auth(&dir), auth("b-latest"));
|
||||
let reset = reset_default_official_profile_inner(super::super::live::OfficialConfigInput {
|
||||
config_dir: scope(&dir),
|
||||
model: None,
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
//! Read-only subscription quota lookup for one saved official login. Credentials
|
||||
//! stay in native code; neither network errors nor response bodies are logged.
|
||||
|
||||
use super::official_profiles::{
|
||||
official_profile_quota_credentials, OfficialProfileQuotaCredentials,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::remote::ensure_crypto_provider;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
const QUOTA_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const RESET_CREDITS_URL: &str = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
||||
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
// The file lock is nonblocking. Concurrent quota and credit queries share this
|
||||
// brief gate while reading credentials, so they cannot fail each other's read.
|
||||
// Neither this gate nor the file lock is held during a network request.
|
||||
static QUERY_CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OfficialQuery {
|
||||
Quota,
|
||||
ResetCredits,
|
||||
}
|
||||
|
||||
impl OfficialQuery {
|
||||
fn url(self) -> &'static str {
|
||||
match self {
|
||||
Self::Quota => QUOTA_URL,
|
||||
Self::ResetCredits => RESET_CREDITS_URL,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Quota => "额度",
|
||||
Self::ResetCredits => "重置次数",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialResetCreditsSnapshot {
|
||||
profile_id: String,
|
||||
available_count: u32,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResetCreditsResponse {
|
||||
// Required and unsigned: unknown or invalid counts must never look like 0.
|
||||
available_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaSnapshot {
|
||||
profile_id: String,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
limits: Vec<OfficialQuotaLimit>,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaLimit {
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
allowed: Option<bool>,
|
||||
limit_reached: Option<bool>,
|
||||
windows: Vec<OfficialQuotaWindow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaWindow {
|
||||
id: String,
|
||||
window_seconds: Option<i64>,
|
||||
used_percent: Option<f64>,
|
||||
remaining_percent: Option<f64>,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct RawWindow {
|
||||
limit_window_seconds: Option<i64>,
|
||||
used_percent: Option<f64>,
|
||||
reset_at: Option<i64>,
|
||||
reset_after_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct RawLimit {
|
||||
allowed: Option<bool>,
|
||||
limit_reached: Option<bool>,
|
||||
primary_window: Option<RawWindow>,
|
||||
secondary_window: Option<RawWindow>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawAdditionalLimit {
|
||||
limit_name: Option<String>,
|
||||
name: Option<String>,
|
||||
metered_feature: Option<String>,
|
||||
rate_limit: Option<RawLimit>,
|
||||
#[serde(flatten)]
|
||||
direct_limit: RawLimit,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawQuotaResponse {
|
||||
account_id: Option<String>,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
rate_limit: Option<RawLimit>,
|
||||
code_review_rate_limit: Option<RawLimit>,
|
||||
additional_rate_limits: Option<Vec<Option<RawAdditionalLimit>>>,
|
||||
}
|
||||
|
||||
fn config_error(message: &str) -> CodexxError {
|
||||
CodexxError::Config(message.to_owned())
|
||||
}
|
||||
|
||||
fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn display_email(value: Option<&str>) -> Option<String> {
|
||||
nonempty(value)
|
||||
.filter(|email| email.len() <= 320 && email.contains('@'))
|
||||
.filter(|email| {
|
||||
!email
|
||||
.chars()
|
||||
.any(|character| character.is_control() || character.is_whitespace())
|
||||
})
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn quota_headers(credentials: &OfficialProfileQuotaCredentials) -> Result<HeaderMap> {
|
||||
let token = nonempty(Some(&credentials.access_token))
|
||||
.ok_or_else(|| config_error("此官方配置尚未登录,请先在 Codex 中登录"))?;
|
||||
let mut authorization = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.map_err(|_| config_error("此官方配置的登录凭据无效,请重新登录"))?;
|
||||
authorization.set_sensitive(true);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, authorization);
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
|
||||
if let Some(account_id) = nonempty(credentials.account_id.as_deref()) {
|
||||
let mut account = HeaderValue::from_str(account_id)
|
||||
.map_err(|_| config_error("此官方配置的账号标识无效,请重新登录"))?;
|
||||
account.set_sensitive(true);
|
||||
headers.insert("ChatGPT-Account-Id", account);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn quota_client() -> Result<Client> {
|
||||
ensure_crypto_provider();
|
||||
Client::builder()
|
||||
.https_only(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(15))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|_| config_error("官方账号查询客户端初始化失败"))
|
||||
}
|
||||
|
||||
fn check_status(status: StatusCode, query: OfficialQuery) -> Result<()> {
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let label = query.label();
|
||||
let message = match status.as_u16() {
|
||||
401 => format!("官方登录已失效,请重新登录此账号后查询{label}(HTTP 401)"),
|
||||
403 => format!("无法访问官方{label}服务(HTTP 403),请稍后重试或检查网络"),
|
||||
429 => format!("官方{label}查询过于频繁,请稍后重试(HTTP 429)"),
|
||||
300..=399 => format!("官方{label}接口发生重定向,已停止请求,请稍后重试"),
|
||||
code @ 500..=599 => format!("官方{label}服务暂不可用(HTTP {code}),请稍后重试"),
|
||||
code => format!("官方{label}查询失败(HTTP {code}),请稍后重试"),
|
||||
};
|
||||
Err(CodexxError::Config(message))
|
||||
}
|
||||
|
||||
fn read_bounded_body(reader: impl Read, content_length: Option<u64>) -> Result<Vec<u8>> {
|
||||
if content_length.is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) {
|
||||
return Err(config_error("官方查询响应过大,已停止读取"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| config_error("读取官方查询响应失败,请稍后重试"))?;
|
||||
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(config_error("官方查询响应过大,已停止读取"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn window(id: &str, raw: RawWindow, checked_at: DateTime<Utc>) -> OfficialQuotaWindow {
|
||||
let used_percent = raw
|
||||
.used_percent
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| value.clamp(0.0, 100.0));
|
||||
let resets_at = raw
|
||||
.reset_at
|
||||
.and_then(|seconds| DateTime::from_timestamp(seconds, 0))
|
||||
.or_else(|| {
|
||||
raw.reset_after_seconds
|
||||
.filter(|seconds| *seconds >= 0)
|
||||
.and_then(ChronoDuration::try_seconds)
|
||||
.and_then(|duration| checked_at.checked_add_signed(duration))
|
||||
})
|
||||
.map(|time| time.to_rfc3339());
|
||||
OfficialQuotaWindow {
|
||||
id: id.to_owned(),
|
||||
window_seconds: raw.limit_window_seconds.filter(|seconds| *seconds > 0),
|
||||
used_percent,
|
||||
remaining_percent: used_percent.map(|value| (100.0 - value).clamp(0.0, 100.0)),
|
||||
resets_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn limit(
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
raw: RawLimit,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> OfficialQuotaLimit {
|
||||
let windows = [
|
||||
("primary", raw.primary_window),
|
||||
("secondary", raw.secondary_window),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(id, raw)| raw.map(|raw| window(id, raw, checked_at)))
|
||||
.collect();
|
||||
OfficialQuotaLimit {
|
||||
id,
|
||||
name,
|
||||
allowed: raw.allowed,
|
||||
limit_reached: raw.limit_reached,
|
||||
windows,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_quota_response(
|
||||
bytes: &[u8],
|
||||
profile_id: &str,
|
||||
credentials: &OfficialProfileQuotaCredentials,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> Result<OfficialQuotaSnapshot> {
|
||||
let raw: RawQuotaResponse = serde_json::from_slice(bytes)
|
||||
.map_err(|_| config_error("官方额度响应格式无法识别,请稍后重试"))?;
|
||||
if nonempty(raw.account_id.as_deref())
|
||||
.zip(nonempty(credentials.account_id.as_deref()))
|
||||
.is_some_and(|(actual, expected)| actual != expected)
|
||||
{
|
||||
return Err(config_error(
|
||||
"额度响应与所选官方账号不一致,请重新登录此配置后重试",
|
||||
));
|
||||
}
|
||||
let mut limits = Vec::new();
|
||||
if let Some(raw) = raw.rate_limit {
|
||||
limits.push(limit("main".to_owned(), None, raw, checked_at));
|
||||
}
|
||||
if let Some(raw) = raw.code_review_rate_limit {
|
||||
limits.push(limit("code_review".to_owned(), None, raw, checked_at));
|
||||
}
|
||||
for (index, additional) in raw
|
||||
.additional_rate_limits
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let Some(additional) = additional else {
|
||||
continue;
|
||||
};
|
||||
let name = nonempty(additional.limit_name.as_deref())
|
||||
.or_else(|| nonempty(additional.name.as_deref()))
|
||||
.or_else(|| nonempty(additional.metered_feature.as_deref()))
|
||||
.map(str::to_owned);
|
||||
limits.push(limit(
|
||||
format!("additional-{index}"),
|
||||
name,
|
||||
additional.rate_limit.unwrap_or(additional.direct_limit),
|
||||
checked_at,
|
||||
));
|
||||
}
|
||||
Ok(OfficialQuotaSnapshot {
|
||||
profile_id: profile_id.to_owned(),
|
||||
email: display_email(raw.email.as_deref())
|
||||
.or_else(|| display_email(credentials.email.as_deref())),
|
||||
plan_type: nonempty(raw.plan_type.as_deref()).map(str::to_owned),
|
||||
limits,
|
||||
checked_at: checked_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
fn credential_fingerprint(credentials: &OfficialProfileQuotaCredentials) -> [u8; 32] {
|
||||
let mut digest = Sha256::new();
|
||||
// Length-delimit components so two different identities cannot have the
|
||||
// same concatenated input. Only this digest is compared after the request.
|
||||
for value in [
|
||||
Some(credentials.access_token.as_str()),
|
||||
credentials.account_id.as_deref(),
|
||||
credentials.email.as_deref(),
|
||||
] {
|
||||
let bytes = value.unwrap_or_default().as_bytes();
|
||||
digest.update((bytes.len() as u64).to_le_bytes());
|
||||
digest.update(bytes);
|
||||
}
|
||||
digest.finalize().into()
|
||||
}
|
||||
|
||||
fn parse_reset_credits_response(
|
||||
bytes: &[u8],
|
||||
profile_id: &str,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> Result<OfficialResetCreditsSnapshot> {
|
||||
let raw: RawResetCreditsResponse = serde_json::from_slice(bytes)
|
||||
.map_err(|_| config_error("官方重置次数响应格式无法识别,请稍后重试"))?;
|
||||
Ok(OfficialResetCreditsSnapshot {
|
||||
profile_id: profile_id.to_owned(),
|
||||
available_count: raw.available_count,
|
||||
checked_at: checked_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_same_credentials(
|
||||
expected: [u8; 32],
|
||||
current: &OfficialProfileQuotaCredentials,
|
||||
) -> Result<()> {
|
||||
if expected != credential_fingerprint(current) {
|
||||
return Err(config_error("此官方账号的登录信息已变化,请重新刷新"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_official_response(
|
||||
query: OfficialQuery,
|
||||
credentials: &OfficialProfileQuotaCredentials,
|
||||
) -> Result<Vec<u8>> {
|
||||
let headers = quota_headers(credentials)?;
|
||||
let label = query.label();
|
||||
let response = quota_client()?
|
||||
.get(query.url())
|
||||
.headers(headers)
|
||||
.send()
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
config_error(&format!("官方{label}查询超时,请检查网络后重试"))
|
||||
} else if error.is_connect() {
|
||||
config_error(&format!("无法连接官方{label}接口,请检查网络或代理设置"))
|
||||
} else {
|
||||
config_error(&format!("官方{label}网络请求失败,请稍后重试"))
|
||||
}
|
||||
})?;
|
||||
check_status(response.status(), query)?;
|
||||
let length = response.content_length();
|
||||
read_bounded_body(response, length)
|
||||
}
|
||||
|
||||
fn read_query_credentials(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<OfficialProfileQuotaCredentials> {
|
||||
let _query_guard = QUERY_CREDENTIAL_LOCK
|
||||
.lock()
|
||||
.map_err(|_| config_error("官方账号查询暂不可用,请重试"))?;
|
||||
let _file_guard = acquire_live_config_lock(codex_dir)
|
||||
.map_err(|_| config_error("官方配置正在修改,请稍后刷新"))?;
|
||||
official_profile_quota_credentials(codex_dir, profile_id)
|
||||
}
|
||||
|
||||
fn read_official_snapshot<T>(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
fetch: impl FnOnce(&OfficialProfileQuotaCredentials) -> Result<Vec<u8>>,
|
||||
parse: impl FnOnce(&[u8], &str, &OfficialProfileQuotaCredentials, DateTime<Utc>) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let credentials = read_query_credentials(codex_dir, profile_id)?;
|
||||
let fingerprint = credential_fingerprint(&credentials);
|
||||
let bytes = fetch(&credentials)?;
|
||||
let snapshot = parse(&bytes, profile_id, &credentials, Utc::now())?;
|
||||
let current = read_query_credentials(codex_dir, profile_id)
|
||||
.map_err(|_| config_error("此官方配置已变化或被删除,请重新选择账号后查询"))?;
|
||||
ensure_same_credentials(fingerprint, ¤t)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_profile_quota_inner(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialQuotaSnapshot> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
read_official_snapshot(
|
||||
&codex_dir,
|
||||
profile_id.trim(),
|
||||
|credentials| fetch_official_response(OfficialQuery::Quota, credentials),
|
||||
parse_quota_response,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_profile_reset_credits_inner(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialResetCreditsSnapshot> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
read_official_snapshot(
|
||||
&codex_dir,
|
||||
profile_id.trim(),
|
||||
|credentials| fetch_official_response(OfficialQuery::ResetCredits, credentials),
|
||||
|bytes, profile_id, _, checked_at| {
|
||||
parse_reset_credits_response(bytes, profile_id, checked_at)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn credentials(account: Option<&str>) -> OfficialProfileQuotaCredentials {
|
||||
OfficialProfileQuotaCredentials {
|
||||
access_token: "synthetic-access-token".to_owned(),
|
||||
account_id: account.map(str::to_owned),
|
||||
email: Some("saved@example.test".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_at() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-09-09T03:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn parse(value: serde_json::Value, account: Option<&str>) -> OfficialQuotaSnapshot {
|
||||
parse_quota_response(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
"official-profile-a",
|
||||
&credentials(account),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_primary_window_can_be_weekly_and_account_id_can_be_absent() {
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"plan_type":"pro", "email":"active@example.test", "account_id":"server-account",
|
||||
"rate_limit":{"allowed":true,"limit_reached":false,"primary_window":{"limit_window_seconds":604800,"used_percent":37.5,"reset_after_seconds":600}},
|
||||
"additional_rate_limits":null,"code_review_rate_limit":null
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(snapshot.profile_id, "official-profile-a");
|
||||
assert_eq!(snapshot.email.as_deref(), Some("active@example.test"));
|
||||
assert_eq!(snapshot.plan_type.as_deref(), Some("pro"));
|
||||
assert_eq!(snapshot.limits.len(), 1);
|
||||
let limit = &snapshot.limits[0];
|
||||
assert_eq!(limit.id, "main");
|
||||
assert_eq!(limit.allowed, Some(true));
|
||||
assert_eq!(limit.windows[0].id, "primary");
|
||||
assert_eq!(limit.windows[0].window_seconds, Some(604800));
|
||||
assert_eq!(limit.windows[0].remaining_percent, Some(62.5));
|
||||
assert_eq!(
|
||||
limit.windows[0].resets_at.as_deref(),
|
||||
Some("2026-09-09T03:10:00+00:00")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_two_windows_preserve_real_durations_and_absolute_reset_priority() {
|
||||
let reset = checked_at().timestamp() + 3600;
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"account_id":"team-a","plan_type":"team",
|
||||
"rate_limit":{"allowed":false,"limit_reached":true,
|
||||
"primary_window":{"limit_window_seconds":18000,"used_percent":100,"reset_at":reset,"reset_after_seconds":20},
|
||||
"secondary_window":{"limit_window_seconds":604800,"used_percent":23.25}}
|
||||
}),
|
||||
Some("team-a"),
|
||||
);
|
||||
let limit = &snapshot.limits[0];
|
||||
assert_eq!(limit.windows.len(), 2);
|
||||
assert_eq!(limit.limit_reached, Some(true));
|
||||
assert_eq!(limit.windows[0].window_seconds, Some(18000));
|
||||
assert_eq!(limit.windows[0].remaining_percent, Some(0.0));
|
||||
assert_eq!(
|
||||
limit.windows[0].resets_at.as_deref(),
|
||||
Some("2026-09-09T04:00:00+00:00")
|
||||
);
|
||||
assert_eq!(limit.windows[1].window_seconds, Some(604800));
|
||||
assert_eq!(limit.windows[1].remaining_percent, Some(76.75));
|
||||
assert!(limit.windows[1].resets_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_review_and_additional_features_remain_separate_limits() {
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"rate_limit":null,
|
||||
"code_review_rate_limit":{"allowed":true,"primary_window":{"used_percent":10,"limit_window_seconds":604800}},
|
||||
"additional_rate_limits":[null,
|
||||
{"limit_name":"Codex Spark","metered_feature":"codex_bengal","rate_limit":{"allowed":true,"primary_window":{"used_percent":40,"limit_window_seconds":18000}}},
|
||||
{"metered_feature":"future_feature","allowed":false,"limit_reached":true,"primary_window":{"used_percent":100,"limit_window_seconds":86400}}]
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(snapshot.limits.len(), 3);
|
||||
assert_eq!(snapshot.limits[0].id, "code_review");
|
||||
assert_eq!(snapshot.limits[1].id, "additional-1");
|
||||
assert_eq!(snapshot.limits[1].name.as_deref(), Some("Codex Spark"));
|
||||
assert_eq!(snapshot.limits[2].name.as_deref(), Some("future_feature"));
|
||||
assert_eq!(snapshot.limits[2].allowed, Some(false));
|
||||
assert_eq!(snapshot.limits[2].windows[0].window_seconds, Some(86400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_usage_is_unknown_instead_of_zero_or_full_remaining() {
|
||||
let snapshot = parse(
|
||||
json!({"rate_limit":{"primary_window":{"limit_window_seconds":18000}}}),
|
||||
None,
|
||||
);
|
||||
let window = &snapshot.limits[0].windows[0];
|
||||
assert!(window.used_percent.is_none());
|
||||
assert!(window.remaining_percent.is_none());
|
||||
assert!(snapshot.limits[0].allowed.is_none());
|
||||
assert!(snapshot.limits[0].limit_reached.is_none());
|
||||
let empty = parse(json!({}), None);
|
||||
assert!(empty.limits.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_percentages_are_clamped_and_nonfinite_values_stay_unknown() {
|
||||
for (used, expected) in [
|
||||
(-20.0, Some(0.0)),
|
||||
(130.0, Some(100.0)),
|
||||
(f64::NAN, None),
|
||||
(f64::INFINITY, None),
|
||||
] {
|
||||
let value = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
used_percent: Some(used),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert_eq!(value.used_percent, expected);
|
||||
assert_eq!(value.remaining_percent, expected.map(|used| 100.0 - used));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_reset_and_window_numbers_do_not_panic_or_invent_dates() {
|
||||
let value = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
limit_window_seconds: Some(-1),
|
||||
reset_at: Some(i64::MAX),
|
||||
reset_after_seconds: Some(i64::MAX),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert!(value.window_seconds.is_none());
|
||||
assert!(value.resets_at.is_none());
|
||||
let negative = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
reset_after_seconds: Some(-10),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert!(negative.resets_at.is_none());
|
||||
let now = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
reset_after_seconds: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert_eq!(now.resets_at.as_deref(), Some("2026-09-09T03:00:00+00:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_nonempty_response_account_is_rejected_without_identifiers_in_error() {
|
||||
let error = parse_quota_response(
|
||||
br#"{"account_id":"other-private-account"}"#,
|
||||
"official-profile-a",
|
||||
&credentials(Some("selected-private-account")),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("不一致"));
|
||||
assert!(!error.contains("private-account"));
|
||||
assert!(!error.contains("synthetic-access-token"));
|
||||
assert!(
|
||||
parse(json!({"account_id":null}), Some("selected-private-account"))
|
||||
.limits
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_uses_response_first_then_safe_saved_display_metadata() {
|
||||
assert_eq!(
|
||||
parse(json!({"email":"response@example.test"}), None)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("response@example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(json!({"email":"bad\n@example.test"}), None)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("saved@example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(json!({}), None).email.as_deref(),
|
||||
Some("saved@example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_has_only_fixed_https_endpoint_and_sensitive_auth_headers() {
|
||||
let request = quota_client()
|
||||
.unwrap()
|
||||
.get(QUOTA_URL)
|
||||
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(request.method(), reqwest::Method::GET);
|
||||
assert_eq!(request.url().as_str(), QUOTA_URL);
|
||||
assert_eq!(request.url().host_str(), Some("chatgpt.com"));
|
||||
assert_eq!(request.url().scheme(), "https");
|
||||
assert!(request.headers()[AUTHORIZATION].is_sensitive());
|
||||
assert_eq!(
|
||||
request.headers()[AUTHORIZATION],
|
||||
"Bearer synthetic-access-token"
|
||||
);
|
||||
assert!(request.headers()["chatgpt-account-id"].is_sensitive());
|
||||
assert_eq!(request.headers()["chatgpt-account-id"], "account-a");
|
||||
assert_eq!(request.headers()[ACCEPT], "application/json");
|
||||
assert_eq!(request.headers()[USER_AGENT], "codex-cli");
|
||||
assert!(!format!("{request:?}").contains("synthetic-access-token"));
|
||||
assert!(!quota_headers(&credentials(None))
|
||||
.unwrap()
|
||||
.contains_key("chatgpt-account-id"));
|
||||
let credits_request = quota_client()
|
||||
.unwrap()
|
||||
.get(OfficialQuery::ResetCredits.url())
|
||||
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(credits_request.method(), reqwest::Method::GET);
|
||||
assert_eq!(credits_request.url().as_str(), RESET_CREDITS_URL);
|
||||
assert_eq!(credits_request.url().scheme(), "https");
|
||||
assert_eq!(credits_request.url().host_str(), Some("chatgpt.com"));
|
||||
assert!(credits_request.headers()[AUTHORIZATION].is_sensitive());
|
||||
assert!(credits_request.headers()["chatgpt-account-id"].is_sensitive());
|
||||
assert!(!format!("{credits_request:?}").contains("synthetic-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_injected_credentials_fail_without_revealing_content() {
|
||||
let mut empty = credentials(None);
|
||||
empty.access_token = " ".to_owned();
|
||||
assert!(quota_headers(&empty)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("尚未登录"));
|
||||
let mut invalid = credentials(None);
|
||||
invalid.access_token = "private-secret\r\nInjected: true".to_owned();
|
||||
let error = quota_headers(&invalid).unwrap_err().to_string();
|
||||
assert!(!error.contains("private-secret"));
|
||||
assert!(!error.contains("Injected"));
|
||||
invalid = credentials(Some("private-account\r\nInjected: true"));
|
||||
let error = quota_headers(&invalid).unwrap_err().to_string();
|
||||
assert!(!error.contains("private-account"));
|
||||
assert!(error.contains("账号标识无效"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_statuses_have_safe_actionable_messages() {
|
||||
for query in [OfficialQuery::Quota, OfficialQuery::ResetCredits] {
|
||||
assert!(check_status(StatusCode::OK, query).is_ok());
|
||||
for (status, expected) in [
|
||||
(401, "重新登录"),
|
||||
(403, "检查网络"),
|
||||
(429, "过于频繁"),
|
||||
(302, "重定向"),
|
||||
(503, "HTTP 503"),
|
||||
] {
|
||||
let error = check_status(StatusCode::from_u16(status).unwrap(), query)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(expected));
|
||||
assert!(error.contains(query.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_limit_applies_even_without_or_with_false_content_length() {
|
||||
assert_eq!(read_bounded_body(&b"{}"[..], Some(2)).unwrap(), b"{}");
|
||||
let large = vec![b'x'; MAX_RESPONSE_BYTES + 1];
|
||||
for content_length in [None, Some(1), Some(large.len() as u64)] {
|
||||
assert!(read_bounded_body(&large[..], content_length)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("响应过大"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_response_never_enters_error_text() {
|
||||
let error = parse_quota_response(
|
||||
b"server accidentally returned private-secret-token",
|
||||
"official-profile-a",
|
||||
&credentials(None),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("格式无法识别"));
|
||||
assert!(!error.contains("private-secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_change_rejects_stale_result_for_token_account_or_email() {
|
||||
let original = credentials(Some("account-a"));
|
||||
let fingerprint = credential_fingerprint(&original);
|
||||
assert!(ensure_same_credentials(fingerprint, &credentials(Some("account-a"))).is_ok());
|
||||
for field in ["token", "account", "email"] {
|
||||
let mut changed = credentials(Some("account-a"));
|
||||
match field {
|
||||
"token" => changed.access_token = "private-replacement-token".into(),
|
||||
"account" => changed.account_id = Some("account-b".into()),
|
||||
_ => changed.email = Some("changed@example.test".into()),
|
||||
}
|
||||
let error = ensure_same_credentials(fingerprint, &changed)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("登录信息已变化"));
|
||||
assert!(!error.contains("private-replacement-token"));
|
||||
assert!(!error.contains("account-b"));
|
||||
assert!(!error.contains("changed@example.test"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_snapshot_contains_only_display_fields_and_no_credentials() {
|
||||
let snapshot = parse(
|
||||
json!({"plan_type":"team","rate_limit":{"primary_window":{"used_percent":40,"limit_window_seconds":18000}}}),
|
||||
Some("account-a"),
|
||||
);
|
||||
let value = serde_json::to_value(&snapshot).unwrap();
|
||||
assert_eq!(value["profileId"], "official-profile-a");
|
||||
assert_eq!(value["planType"], "team");
|
||||
assert_eq!(value["limits"][0]["windows"][0]["remainingPercent"], 60.0);
|
||||
let serialized = value.to_string();
|
||||
assert!(!serialized.contains("access_token"));
|
||||
assert!(!serialized.contains("synthetic-access-token"));
|
||||
assert!(!serialized.contains("account-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credits_uses_available_count_and_returns_no_credit_details() {
|
||||
let raw = json!({
|
||||
"available_count": 3,
|
||||
"total_earned_count": 0,
|
||||
"credits": [{"id":"private-credit-id", "status":"available", "title":"private-credit-title"}],
|
||||
"immediate_reset_purchase_eligible": true
|
||||
});
|
||||
let snapshot = parse_reset_credits_response(
|
||||
&serde_json::to_vec(&raw).unwrap(),
|
||||
"official-profile-a",
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(snapshot).unwrap(),
|
||||
json!({
|
||||
"profileId":"official-profile-a",
|
||||
"availableCount":3,
|
||||
"checkedAt":"2026-09-09T03:00:00+00:00"
|
||||
})
|
||||
);
|
||||
let zero = parse_reset_credits_response(
|
||||
br#"{"available_count":0,"credits":[{"status":"available"}]}"#,
|
||||
"official-profile-a",
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(zero.available_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credits_unknown_or_invalid_counts_are_not_reported_as_zero() {
|
||||
for raw in [
|
||||
"{}",
|
||||
"null",
|
||||
r#"{"available_count":null}"#,
|
||||
r#"{"available_count":-1}"#,
|
||||
r#"{"available_count":1.5}"#,
|
||||
r#"{"available_count":"3"}"#,
|
||||
r#"{"available_count":true}"#,
|
||||
r#"{"available_count":4294967296}"#,
|
||||
"private-malformed-response",
|
||||
] {
|
||||
let error =
|
||||
parse_reset_credits_response(raw.as_bytes(), "official-profile-a", checked_at())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("重置次数响应格式无法识别"));
|
||||
assert!(!error.contains("private-malformed-response"));
|
||||
}
|
||||
}
|
||||
|
||||
fn query_fixture(label: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-official-query-{label}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
crate::file_io::write_text(
|
||||
&crate::config_path(&dir),
|
||||
"model_provider = \"openai\"\nmodel = \"synthetic-model\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::file_io::write_json(
|
||||
&crate::auth_path(&dir),
|
||||
&json!({
|
||||
"auth_mode":"chatgpt", "tokens":{
|
||||
"access_token":"synthetic-query-token", "account_id":"synthetic-account"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_and_reset_queries_overlap_without_holding_the_live_config_lock() {
|
||||
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
|
||||
use std::sync::mpsc;
|
||||
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let dir = query_fixture("parallel");
|
||||
// Initialize the test database before starting the two independent reads.
|
||||
read_query_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap();
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (quota_release_tx, quota_release_rx) = mpsc::channel();
|
||||
let (credits_release_tx, credits_release_rx) = mpsc::channel();
|
||||
std::thread::scope(|scope| {
|
||||
let quota_started = started_tx.clone();
|
||||
let quota_dir = &dir;
|
||||
let quota = scope.spawn(move || {
|
||||
read_official_snapshot(
|
||||
quota_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
quota_started.send("quota").unwrap();
|
||||
quota_release_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap();
|
||||
Ok(b"{}".to_vec())
|
||||
},
|
||||
parse_quota_response,
|
||||
)
|
||||
});
|
||||
let credits_dir = &dir;
|
||||
let credits = scope.spawn(move || {
|
||||
read_official_snapshot(
|
||||
credits_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
started_tx.send("credits").unwrap();
|
||||
credits_release_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap();
|
||||
Ok(br#"{"available_count":3}"#.to_vec())
|
||||
},
|
||||
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
|
||||
)
|
||||
});
|
||||
// Both requests must start before either can finish. A lock held over
|
||||
// the simulated network wait would make this fail instead of hang.
|
||||
let first = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
let second = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert_ne!(first, second);
|
||||
drop(acquire_live_config_lock(&dir).unwrap());
|
||||
quota_release_tx.send(()).unwrap();
|
||||
credits_release_tx.send(()).unwrap();
|
||||
assert_eq!(
|
||||
quota.join().unwrap().unwrap().profile_id,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID
|
||||
);
|
||||
assert_eq!(credits.join().unwrap().unwrap().available_count, 3);
|
||||
});
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_query_rejects_login_replaced_during_request() {
|
||||
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
|
||||
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let dir = query_fixture("changed-login");
|
||||
let error = read_official_snapshot(
|
||||
&dir, DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
let _guard = acquire_live_config_lock(&dir).unwrap();
|
||||
crate::file_io::write_json(&crate::auth_path(&dir), &json!({
|
||||
"auth_mode":"chatgpt", "tokens":{
|
||||
"access_token":"synthetic-replacement-token", "account_id":"synthetic-account"
|
||||
}
|
||||
})).unwrap();
|
||||
Ok(br#"{"available_count":3}"#.to_vec())
|
||||
},
|
||||
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
|
||||
).unwrap_err().to_string();
|
||||
assert!(error.contains("登录信息已变化"));
|
||||
assert!(!error.contains("synthetic-replacement-token"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
fn selected_provider_id_on_connection(
|
||||
pub(super) fn selected_provider_id_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{clear_provider_selections_on_connection, open_store as open_db};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::{now_rfc3339, sanitize_id};
|
||||
use rusqlite::{params, Connection, TransactionBehavior};
|
||||
use crate::paths::normalized_path_scope;
|
||||
use crate::{now_rfc3339, resolve_codex_dir, sanitize_id};
|
||||
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use toml_edit::{value, DocumentMut};
|
||||
@@ -17,6 +18,8 @@ pub(crate) struct SavedProvider {
|
||||
pub(crate) toml_config: Option<String>,
|
||||
pub(crate) wire_api: String,
|
||||
pub(crate) requires_openai_auth: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) model_mappings: Vec<super::model_catalog::ProviderModelMapping>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -282,6 +285,62 @@ pub(crate) fn matching_saved_provider_ids_for_live(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_live_provider_route(live: &SavedProvider, saved: &SavedProvider) -> bool {
|
||||
if !same_provider_endpoint(live, saved) {
|
||||
return false;
|
||||
}
|
||||
match (
|
||||
effective_provider_api_key(live),
|
||||
effective_provider_api_key(saved),
|
||||
) {
|
||||
(Some(live_key), Some(saved_key)) => live_key == saved_key,
|
||||
// Missing live authentication is tolerated for legacy providers, but
|
||||
// without credentials a changed name is not enough evidence to adopt it.
|
||||
_ => {
|
||||
normalized_provider_name(&live.provider_name)
|
||||
== normalized_provider_name(&saved.provider_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved record keeps its identity when Codex changes the runtime model or
|
||||
/// display name. Prefer the remembered record only while its route/credential
|
||||
/// still matches; otherwise fall back to the existing profile matching rules.
|
||||
/// This lookup never changes a selection or writes a provider.
|
||||
pub(crate) fn matching_saved_provider_ids_for_live_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &std::path::Path,
|
||||
live: &SavedProvider,
|
||||
providers: &[SavedProvider],
|
||||
) -> Result<Vec<String>> {
|
||||
if let Some(selected) = super::selection::selected_provider_id_on_connection(conn, codex_dir)? {
|
||||
if providers
|
||||
.iter()
|
||||
.any(|provider| provider.id == selected && same_live_provider_route(live, provider))
|
||||
{
|
||||
return Ok(vec![selected]);
|
||||
}
|
||||
}
|
||||
let matches = matching_saved_provider_ids_for_live(live, providers)
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
providers
|
||||
.iter()
|
||||
.any(|provider| &provider.id == id && same_live_provider_route(live, provider))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !matches.is_empty() {
|
||||
return Ok(matches);
|
||||
}
|
||||
// Several same-API records remain ambiguous without a remembered ID. Return
|
||||
// all candidates so callers can leave them alone rather than create a copy.
|
||||
Ok(providers
|
||||
.iter()
|
||||
.filter(|provider| same_live_provider_route(live, provider))
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn unique_saved_provider_id_for_live(
|
||||
live: &SavedProvider,
|
||||
@@ -301,6 +360,13 @@ fn saved_provider_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SavedPro
|
||||
toml_config: row.get(5)?,
|
||||
wire_api: row.get(6)?,
|
||||
requires_openai_auth: row.get::<_, i64>(7)? != 0,
|
||||
model_mappings: serde_json::from_str(&row.get::<_, String>(8)?).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
8,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(error),
|
||||
)
|
||||
})?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -308,7 +374,7 @@ fn stored_providers_on_connection(conn: &Connection) -> Result<Vec<StoredProvide
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
|
||||
requires_openai_auth, created_at, updated_at, source, source_id
|
||||
requires_openai_auth, model_mappings_json, created_at, updated_at, source, source_id
|
||||
FROM providers
|
||||
ORDER BY created_at ASC, rowid ASC",
|
||||
)
|
||||
@@ -317,10 +383,10 @@ fn stored_providers_on_connection(conn: &Connection) -> Result<Vec<StoredProvide
|
||||
.query_map([], |row| {
|
||||
Ok(StoredProvider {
|
||||
provider: saved_provider_from_row(row)?,
|
||||
created_at: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
source: row.get(10)?,
|
||||
source_id: row.get(11)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
source: row.get(11)?,
|
||||
source_id: row.get(12)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -346,6 +412,94 @@ pub(crate) fn list_saved_providers_inner() -> Result<Vec<SavedProvider>> {
|
||||
list_saved_providers_on_connection(&conn)
|
||||
}
|
||||
|
||||
fn validate_provider_card_order(order: &[String]) -> Result<()> {
|
||||
if order.len() > 4096 {
|
||||
return Err(CodexxError::Config("供应商排序项过多".to_string()));
|
||||
}
|
||||
let mut seen = HashSet::with_capacity(order.len());
|
||||
for key in order {
|
||||
let valid_key = key.split_once(':').is_some_and(|(source, id)| {
|
||||
matches!(source, "official" | "local" | "detected")
|
||||
&& !id.trim().is_empty()
|
||||
&& id.trim() == id
|
||||
&& key.len() <= 512
|
||||
&& !key.chars().any(char::is_control)
|
||||
});
|
||||
if !valid_key {
|
||||
return Err(CodexxError::Config(
|
||||
"供应商排序包含无效卡片标识".to_string(),
|
||||
));
|
||||
}
|
||||
if !seen.insert(key) {
|
||||
return Err(CodexxError::Config("供应商排序包含重复卡片".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_provider_order_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>> {
|
||||
let json: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT order_json FROM provider_card_orders WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
json.map(|json| {
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|error| CodexxError::Database(format!("读取供应商排序失败: {error}")))
|
||||
})
|
||||
.transpose()
|
||||
.map(Option::unwrap_or_default)
|
||||
}
|
||||
|
||||
pub(crate) fn get_provider_order_inner(config_dir: Option<String>) -> Result<Vec<String>> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
get_provider_order_on_connection(&open_db()?, &codex_dir)
|
||||
}
|
||||
|
||||
fn save_provider_order_on_connection(
|
||||
conn: &mut Connection,
|
||||
codex_dir: &std::path::Path,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
validate_provider_card_order(&order)?;
|
||||
let json = serde_json::to_string(&order)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化供应商排序失败: {error}")))?;
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
// Keep the preference independent of provider records. A refresh, import,
|
||||
// edit, or temporary detected row must not change the user's chosen order;
|
||||
// the UI ignores absent keys and appends newly added cards.
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO provider_card_orders (codex_dir, order_json, updated_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(codex_dir) DO UPDATE SET
|
||||
order_json = excluded.order_json,
|
||||
updated_at = excluded.updated_at",
|
||||
params![normalized_path_scope(codex_dir), json, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
pub(crate) fn save_provider_order_inner(
|
||||
config_dir: Option<String>,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
save_provider_order_on_connection(&mut open_db()?, &codex_dir, order)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_by_id_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
@@ -353,7 +507,7 @@ pub(crate) fn provider_by_id_on_connection(
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
|
||||
requires_openai_auth
|
||||
requires_openai_auth, model_mappings_json
|
||||
FROM providers WHERE id = ?1 LIMIT 1",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -380,14 +534,16 @@ fn write_provider_with_origin(
|
||||
origin: Option<(&str, &str)>,
|
||||
) -> Result<()> {
|
||||
let now = now_rfc3339();
|
||||
let model_mappings_json = serde_json::to_string(&provider.model_mappings)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化模型映射失败: {error}")))?;
|
||||
let (source, source_id) = origin
|
||||
.map(|(source, source_id)| (source, Some(source_id)))
|
||||
.unwrap_or((MANUAL_PROVIDER_SOURCE, None));
|
||||
conn.execute(
|
||||
"INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, toml_config, wire_api,
|
||||
requires_openai_auth, source, source_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
|
||||
requires_openai_auth, model_mappings_json, source, source_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
provider_name = excluded.provider_name,
|
||||
base_url = excluded.base_url,
|
||||
@@ -396,6 +552,7 @@ fn write_provider_with_origin(
|
||||
toml_config = excluded.toml_config,
|
||||
wire_api = excluded.wire_api,
|
||||
requires_openai_auth = excluded.requires_openai_auth,
|
||||
model_mappings_json = excluded.model_mappings_json,
|
||||
source = CASE
|
||||
WHEN excluded.source_id IS NULL THEN providers.source
|
||||
ELSE excluded.source
|
||||
@@ -414,6 +571,7 @@ fn write_provider_with_origin(
|
||||
provider.toml_config,
|
||||
provider.wire_api,
|
||||
if provider.requires_openai_auth { 1 } else { 0 },
|
||||
model_mappings_json,
|
||||
source,
|
||||
source_id,
|
||||
now,
|
||||
@@ -473,6 +631,9 @@ fn merge_authoritative_import(
|
||||
if incoming.wire_api.trim().is_empty() {
|
||||
incoming.wire_api = existing.wire_api.clone();
|
||||
}
|
||||
if incoming.model_mappings.is_empty() {
|
||||
incoming.model_mappings = existing.model_mappings.clone();
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
@@ -709,6 +870,7 @@ fn sync_provider_toml_from_fields(provider: &mut SavedProvider) -> Result<()> {
|
||||
CodexxError::Config(format!("供应商 TOML 缺少 [model_providers.{provider_id}]"))
|
||||
})?;
|
||||
table["name"] = value(provider.provider_name.clone());
|
||||
super::transport::configure_third_party_transport(table, &provider.base_url, true);
|
||||
table["base_url"] = value(provider.base_url.clone());
|
||||
table["wire_api"] = value(provider.wire_api.clone());
|
||||
table["requires_openai_auth"] = value(provider.requires_openai_auth);
|
||||
@@ -725,6 +887,8 @@ pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedP
|
||||
if raw_id.is_empty() {
|
||||
return Err(CodexxError::Config("provider id 不能为空".to_string()));
|
||||
}
|
||||
let model_mappings =
|
||||
super::model_catalog::normalize_mappings(&provider.model_mappings, &provider.model)?;
|
||||
let mut normalized = SavedProvider {
|
||||
id: custom_provider_id(raw_id),
|
||||
provider_name: provider.provider_name.trim().to_string(),
|
||||
@@ -744,6 +908,7 @@ pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedP
|
||||
provider.wire_api.trim().to_string()
|
||||
},
|
||||
requires_openai_auth: provider.requires_openai_auth,
|
||||
model_mappings,
|
||||
};
|
||||
if normalized.provider_name.is_empty() {
|
||||
return Err(CodexxError::Config("供应商名称不能为空".to_string()));
|
||||
@@ -846,7 +1011,21 @@ fn duplicate_provider_on_connection(
|
||||
})?;
|
||||
// Adopt an unsaved detected original before adding its copy. Otherwise
|
||||
// the copy would become the only matching row and appear to be enabled.
|
||||
if matching_saved_provider_ids_for_live(&detected, &saved).is_empty() {
|
||||
let detected_matches = matching_saved_provider_ids_for_live_on_connection(
|
||||
&transaction,
|
||||
codex_dir,
|
||||
&detected,
|
||||
&saved,
|
||||
)?;
|
||||
if let [saved_id] = detected_matches.as_slice() {
|
||||
if let Some(original) = saved.iter().find(|provider| &provider.id == saved_id) {
|
||||
detected.id = original.id.clone();
|
||||
detected.model_mappings = original.model_mappings.clone();
|
||||
if detected.api_key.is_none() {
|
||||
detected.api_key = original.api_key.clone();
|
||||
}
|
||||
}
|
||||
} else if detected_matches.is_empty() {
|
||||
detected.id = unique_provider_id_on_connection(
|
||||
&transaction,
|
||||
&custom_provider_id(&detected.provider_name),
|
||||
@@ -860,7 +1039,12 @@ fn duplicate_provider_on_connection(
|
||||
super::reconcile_active_provider_on_connection(
|
||||
&transaction,
|
||||
codex_dir,
|
||||
&matching_saved_provider_ids_for_live(live, &saved),
|
||||
&matching_saved_provider_ids_for_live_on_connection(
|
||||
&transaction,
|
||||
codex_dir,
|
||||
live,
|
||||
&saved,
|
||||
)?,
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
@@ -910,6 +1094,7 @@ pub(crate) fn save_provider_with_rollback_inner(
|
||||
}
|
||||
|
||||
pub(crate) fn save_detected_provider_with_rollback_inner(
|
||||
codex_dir: &std::path::Path,
|
||||
mut live: SavedProvider,
|
||||
) -> Result<Option<ProviderStoreRollback>> {
|
||||
let mut conn = open_db()?;
|
||||
@@ -921,9 +1106,13 @@ pub(crate) fn save_detected_provider_with_rollback_inner(
|
||||
.iter()
|
||||
.map(|stored| stored.provider.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let matches = matching_saved_provider_ids_for_live(&live, &saved);
|
||||
let matches =
|
||||
matching_saved_provider_ids_for_live_on_connection(&transaction, codex_dir, &live, &saved)?;
|
||||
match matches.as_slice() {
|
||||
[saved_id] => {
|
||||
if let Some(saved) = saved.iter().find(|provider| &provider.id == saved_id) {
|
||||
live.model_mappings = saved.model_mappings.clone();
|
||||
}
|
||||
if live.api_key.is_none() {
|
||||
live.api_key = saved
|
||||
.iter()
|
||||
@@ -954,11 +1143,13 @@ pub(crate) fn save_detected_provider_with_rollback_inner(
|
||||
|
||||
fn insert_stored_provider(conn: &Connection, stored: &StoredProvider) -> Result<()> {
|
||||
let provider = &stored.provider;
|
||||
let model_mappings_json = serde_json::to_string(&provider.model_mappings)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化模型映射失败: {error}")))?;
|
||||
conn.execute(
|
||||
"INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, toml_config, wire_api,
|
||||
requires_openai_auth, source, source_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
requires_openai_auth, model_mappings_json, source, source_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
provider.id,
|
||||
provider.provider_name,
|
||||
@@ -968,6 +1159,7 @@ fn insert_stored_provider(conn: &Connection, stored: &StoredProvider) -> Result<
|
||||
provider.toml_config,
|
||||
provider.wire_api,
|
||||
if provider.requires_openai_auth { 1 } else { 0 },
|
||||
model_mappings_json,
|
||||
stored.source,
|
||||
stored.source_id,
|
||||
stored.created_at,
|
||||
@@ -1099,6 +1291,7 @@ mod tests {
|
||||
model TEXT NOT NULL, api_key TEXT, toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
||||
@@ -1117,6 +1310,7 @@ mod tests {
|
||||
toml_config: None,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,6 +1324,300 @@ mod tests {
|
||||
conn
|
||||
}
|
||||
|
||||
fn provider_order_test_connection() -> Connection {
|
||||
let conn = test_connection();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY, order_json TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_persists_across_connections_and_keeps_config_scopes_independent() {
|
||||
let database_file = tempfile::NamedTempFile::new().unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let other_directory = tempfile::tempdir().unwrap();
|
||||
let mut conn = Connection::open(database_file.path()).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY, order_json TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(get_provider_order_on_connection(&conn, directory.path())
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
let chosen = vec![
|
||||
"local:shared-id".to_string(),
|
||||
"official:shared-id".to_string(),
|
||||
"detected:detected-自定义".to_string(),
|
||||
"official:openai-official".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
save_provider_order_on_connection(&mut conn, directory.path(), chosen.clone()).unwrap(),
|
||||
chosen
|
||||
);
|
||||
let second = vec!["official:another-account".to_string()];
|
||||
save_provider_order_on_connection(&mut conn, other_directory.path(), second.clone())
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
let reopened = Connection::open(database_file.path()).unwrap();
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&reopened, directory.path()).unwrap(),
|
||||
chosen
|
||||
);
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&reopened, other_directory.path()).unwrap(),
|
||||
second
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn provider_card_order_resolves_linked_config_directory_to_the_same_scope() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let alias_root = tempfile::tempdir().unwrap();
|
||||
let alias = alias_root.path().join("linked-config");
|
||||
std::os::unix::fs::symlink(directory.path(), &alias).unwrap();
|
||||
let mut conn = provider_order_test_connection();
|
||||
let order = vec!["local:linked-provider".to_string()];
|
||||
save_provider_order_on_connection(&mut conn, &alias, order.clone()).unwrap();
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory.path()).unwrap(),
|
||||
order
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_survives_provider_refresh_edits_additions_and_deletions() {
|
||||
let mut conn = provider_order_test_connection();
|
||||
let directory = std::path::Path::new("/fixture/provider-order-lifecycle");
|
||||
let original = provider("ordered-provider", "Imported", Some("fixture-key"));
|
||||
upsert_ccswitch_provider_on_connection(&conn, original.clone(), "ordered-source").unwrap();
|
||||
let order = vec![
|
||||
"local:ordered-provider".to_string(),
|
||||
"official:openai-official".to_string(),
|
||||
"detected:detected-temporary".to_string(),
|
||||
];
|
||||
save_provider_order_on_connection(&mut conn, directory, order.clone()).unwrap();
|
||||
assert_eq!(
|
||||
provider_by_id_on_connection(&conn, &original.id).unwrap(),
|
||||
Some(original)
|
||||
);
|
||||
|
||||
let fresh = provider("external-id", "Refreshed import", Some("fixture-key"));
|
||||
let refreshed = upsert_ccswitch_provider_on_connection(&conn, fresh, "ordered-source")
|
||||
.unwrap()
|
||||
.provider;
|
||||
assert_eq!(refreshed.id, "ordered-provider");
|
||||
let mut edited = refreshed;
|
||||
edited.model = "other-model".to_string();
|
||||
save_manual_provider_on_connection(&conn, edited).unwrap();
|
||||
save_manual_provider_on_connection(
|
||||
&conn,
|
||||
provider("new-provider", "New provider", Some("new-fixture-key")),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM providers WHERE id = 'ordered-provider'", [])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory).unwrap(),
|
||||
order
|
||||
);
|
||||
save_provider_order_on_connection(&mut conn, directory, Vec::new()).unwrap();
|
||||
assert!(get_provider_order_on_connection(&conn, directory)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(provider_by_id_on_connection(&conn, "new-provider")
|
||||
.unwrap()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_rejects_invalid_or_duplicate_keys_without_overwriting_saved_order() {
|
||||
let mut conn = provider_order_test_connection();
|
||||
let directory = std::path::Path::new("/fixture/provider-order-validation");
|
||||
let order = vec!["local:kept-provider".to_string()];
|
||||
save_provider_order_on_connection(&mut conn, directory, order.clone()).unwrap();
|
||||
for invalid in [
|
||||
vec![
|
||||
"local:kept-provider".to_string(),
|
||||
"local:kept-provider".to_string(),
|
||||
],
|
||||
vec!["unknown:provider".to_string()],
|
||||
vec!["local:".to_string()],
|
||||
vec!["official: account".to_string()],
|
||||
vec!["detected:invalid\nprovider".to_string()],
|
||||
vec![format!("local:{}", "x".repeat(512))],
|
||||
(0..4097).map(|id| format!("local:{id}")).collect(),
|
||||
] {
|
||||
assert!(save_provider_order_on_connection(&mut conn, directory, invalid).is_err());
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory).unwrap(),
|
||||
order
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_card_order_database_failure_rolls_back_the_entire_order() {
|
||||
let mut conn = provider_order_test_connection();
|
||||
let directory = std::path::Path::new("/fixture/provider-order-rollback");
|
||||
let before = vec![
|
||||
"official:openai-official".to_string(),
|
||||
"local:kept".to_string(),
|
||||
];
|
||||
save_provider_order_on_connection(&mut conn, directory, before.clone()).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER fail_provider_card_order BEFORE UPDATE ON provider_card_orders
|
||||
BEGIN SELECT RAISE(ABORT, 'fixture order persistence failure'); END;",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(save_provider_order_on_connection(
|
||||
&mut conn,
|
||||
directory,
|
||||
vec![
|
||||
"local:kept".to_string(),
|
||||
"official:openai-official".to_string()
|
||||
],
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
get_provider_order_on_connection(&conn, directory).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(
|
||||
conn.is_autocommit(),
|
||||
"failed save must release its transaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_mappings_roundtrip_and_copy_preserve_independent_records() {
|
||||
let mut conn = copy_test_connection();
|
||||
let mut original = provider("mapping-original", "Original", Some("fixture-key"));
|
||||
original.model_mappings = vec![
|
||||
super::super::model_catalog::ProviderModelMapping {
|
||||
model: " gpt-5.5 ".into(),
|
||||
display_name: "GPT".into(),
|
||||
context_window: None,
|
||||
},
|
||||
super::super::model_catalog::ProviderModelMapping {
|
||||
model: "deepseek-chat".into(),
|
||||
display_name: "DeepSeek".into(),
|
||||
context_window: Some(128000),
|
||||
},
|
||||
];
|
||||
let original = save_manual_provider_on_connection(&conn, original).unwrap();
|
||||
assert_eq!(original.model_mappings[0].model, "gpt-5.5");
|
||||
assert_eq!(
|
||||
provider_by_id_on_connection(&conn, &original.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.model_mappings,
|
||||
original.model_mappings
|
||||
);
|
||||
let copied = duplicate_provider_on_connection(
|
||||
&mut conn,
|
||||
std::path::Path::new("/fixture/model-copy"),
|
||||
Some(original.clone()),
|
||||
Some(&original.id),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(copied.provider.id, original.id);
|
||||
assert_eq!(copied.provider.model_mappings, original.model_mappings);
|
||||
let mut detected = original.clone();
|
||||
detected.id = "custom".into();
|
||||
detected.model = "deepseek-chat".into();
|
||||
detected.model_mappings.clear();
|
||||
let detected_copy = duplicate_provider_on_connection(
|
||||
&mut conn,
|
||||
std::path::Path::new("/fixture/model-copy"),
|
||||
Some(detected),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
detected_copy.provider.model_mappings,
|
||||
original.model_mappings
|
||||
);
|
||||
let mut edited = copied.provider;
|
||||
edited.model_mappings[1].display_name = "Independent copy".into();
|
||||
save_manual_provider_on_connection(&conn, edited).unwrap();
|
||||
assert_eq!(
|
||||
provider_by_id_on_connection(&conn, &original.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.model_mappings,
|
||||
original.model_mappings
|
||||
);
|
||||
let serialized = serde_json::to_value(&original).unwrap();
|
||||
assert_eq!(serialized["modelMappings"][1]["contextWindow"], 128000);
|
||||
let mut old_payload = serialized;
|
||||
old_payload.as_object_mut().unwrap().remove("modelMappings");
|
||||
assert!(serde_json::from_value::<SavedProvider>(old_payload)
|
||||
.unwrap()
|
||||
.model_mappings
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_switch_refresh_preserves_locally_saved_model_mappings() {
|
||||
let conn = copy_test_connection();
|
||||
let mut original = provider("mapping-import", "Imported", Some("fixture-key"));
|
||||
original.model_mappings = vec![super::super::model_catalog::ProviderModelMapping {
|
||||
model: "gpt-5.5".into(),
|
||||
display_name: "Local label".into(),
|
||||
context_window: Some(256000),
|
||||
}];
|
||||
upsert_ccswitch_provider_on_connection(&conn, original.clone(), "mapping-source").unwrap();
|
||||
let incoming = provider("external-id", "Fresh import", Some("fixture-key"));
|
||||
let refreshed =
|
||||
upsert_ccswitch_provider_on_connection(&conn, incoming, "mapping-source").unwrap();
|
||||
assert_eq!(refreshed.provider.id, original.id);
|
||||
assert_eq!(refreshed.provider.provider_name, "Fresh import");
|
||||
assert_eq!(refreshed.provider.model_mappings, original.model_mappings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_mapping_rollback_restores_mappings_and_origin_metadata() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let mut original = provider("mapping-rollback-record", "Original", Some("fixture-key"));
|
||||
original.model_mappings = vec![super::super::model_catalog::ProviderModelMapping {
|
||||
model: "gpt-5.5".into(),
|
||||
display_name: "Original model".into(),
|
||||
context_window: Some(128000),
|
||||
}];
|
||||
let conn = open_db().unwrap();
|
||||
upsert_ccswitch_provider_on_connection(&conn, original.clone(), "mapping-rollback-source")
|
||||
.unwrap();
|
||||
let metadata = |conn: &Connection| {
|
||||
conn.query_row(
|
||||
"SELECT source, source_id, created_at, updated_at FROM providers WHERE id = 'mapping-rollback-record'", [],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?)),
|
||||
).unwrap()
|
||||
};
|
||||
let before = metadata(&conn);
|
||||
let mut changed = original.clone();
|
||||
changed.model_mappings[0].display_name = "Changed".into();
|
||||
changed.model_mappings[0].context_window = Some(1000000);
|
||||
let (_, rollback) = save_provider_with_rollback_inner(changed).unwrap();
|
||||
rollback_provider_store_inner(rollback).unwrap();
|
||||
assert_eq!(
|
||||
provider_by_id_on_connection(&conn, &original.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.model_mappings,
|
||||
original.model_mappings
|
||||
);
|
||||
assert_eq!(metadata(&conn), before);
|
||||
drop(conn);
|
||||
delete_provider_inner(&original.id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_copy_preserves_original_selection_and_complete_template() {
|
||||
let mut conn = copy_test_connection();
|
||||
@@ -1642,6 +2130,7 @@ enabled = true
|
||||
toml_config: Some("\n".to_string()),
|
||||
wire_api: String::new(),
|
||||
requires_openai_auth: false,
|
||||
model_mappings: Vec::new(),
|
||||
};
|
||||
let result = upsert_ccswitch_provider_on_connection(&conn, missing, "cc-row")
|
||||
.expect("fill missing import fields from the existing source row");
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use toml_edit::{value, Item, TableLike};
|
||||
|
||||
/// Match the official API exactly. A proxy that forwards DeepSeek may support
|
||||
/// WebSockets independently, so model names and partial host matches are unsafe.
|
||||
pub(crate) fn is_deepseek_http_endpoint(base_url: &str) -> bool {
|
||||
reqwest::Url::parse(base_url.trim()).is_ok_and(|url| {
|
||||
matches!(url.scheme(), "http" | "https") && url.host_str() == Some("api.deepseek.com")
|
||||
})
|
||||
}
|
||||
|
||||
/// A provider's WebSocket capability belongs to its endpoint, not the common
|
||||
/// Codex config inherited when creating a provider. Call before replacing URL.
|
||||
pub(super) fn configure_third_party_transport(
|
||||
table: &mut dyn TableLike,
|
||||
base_url: &str,
|
||||
own_template: bool,
|
||||
) {
|
||||
let same_endpoint = table
|
||||
.get("base_url")
|
||||
.and_then(Item::as_str)
|
||||
.is_some_and(|old| {
|
||||
super::store::canonical_provider_base_url(old)
|
||||
== super::store::canonical_provider_base_url(base_url)
|
||||
});
|
||||
let preserve_explicit = own_template
|
||||
&& same_endpoint
|
||||
&& !is_deepseek_http_endpoint(base_url)
|
||||
&& table
|
||||
.get("supports_websockets")
|
||||
.and_then(Item::as_bool)
|
||||
.is_some();
|
||||
if !preserve_explicit {
|
||||
// Explicit false also overrides Codex's built-in provider defaults if a
|
||||
// user-chosen provider ID happens to shadow a built-in provider.
|
||||
let decor = table
|
||||
.get("supports_websockets")
|
||||
.and_then(Item::as_value)
|
||||
.map(|value| value.decor().clone());
|
||||
let mut replacement = value(false);
|
||||
if let Some(decor) = decor {
|
||||
*replacement
|
||||
.as_value_mut()
|
||||
.expect("boolean value")
|
||||
.decor_mut() = decor;
|
||||
}
|
||||
table.insert("supports_websockets", replacement);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use toml_edit::Table;
|
||||
|
||||
#[test]
|
||||
fn deepseek_host_matching_does_not_disable_proxies() {
|
||||
assert!(is_deepseek_http_endpoint("https://api.deepseek.com/v1/"));
|
||||
assert!(is_deepseek_http_endpoint("HTTPS://API.DEEPSEEK.COM"));
|
||||
for endpoint in [
|
||||
"https://deepseek.proxy.example/v1",
|
||||
"https://api.deepseek.com.proxy.example/v1",
|
||||
"https://api.deepseek.com@proxy.example/v1",
|
||||
"https://proxy.example/api.deepseek.com",
|
||||
"invalid api.deepseek.com",
|
||||
] {
|
||||
assert!(!is_deepseek_http_endpoint(endpoint), "{endpoint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_ownership_controls_websocket_inheritance() {
|
||||
for (source, destination, own, expected) in [
|
||||
(
|
||||
"https://proxy.example/v1",
|
||||
"https://proxy.example/v1/",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"https://proxy.example/v1",
|
||||
"https://proxy.example/v1",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://old.example/v1",
|
||||
"https://new.example/v1",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://api.deepseek.com",
|
||||
"https://api.deepseek.com",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let mut table = Table::new();
|
||||
table["base_url"] = value(source);
|
||||
table["supports_websockets"] = value(true);
|
||||
table["request_max_retries"] = value(9);
|
||||
configure_third_party_transport(&mut table, destination, own);
|
||||
assert_eq!(table["supports_websockets"].as_bool(), Some(expected));
|
||||
assert_eq!(table["request_max_retries"].as_integer(), Some(9));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
use super::storage::{
|
||||
source_text_is_subagent, sqlite_subagent_thread_ids, thread_source_is_internal,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
|
||||
use rusqlite::{params_from_iter, types::Value as SqlValue, Connection, OpenFlags};
|
||||
@@ -81,6 +84,59 @@ fn timestamp_expr(columns: &HashSet<String>, ms_column: &str, seconds_column: &s
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog visibility is independent of the thread index. A stale or incomplete
|
||||
// index must never turn a source-marked internal catalog row into a user thread.
|
||||
pub(super) fn catalog_internal_thread_ids(
|
||||
conn: &Connection,
|
||||
columns: &HashSet<String>,
|
||||
) -> Result<HashSet<String>> {
|
||||
if !columns.contains("thread_id") {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
let source = text_expr(columns, "source_kind", "NULL");
|
||||
let thread_source = text_expr(columns, "thread_source", "NULL");
|
||||
let mut statement = conn
|
||||
.prepare(&format!(
|
||||
"SELECT thread_id, {source}, {thread_source} FROM local_thread_catalog"
|
||||
))
|
||||
.map_err(database_error)?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(database_error)?;
|
||||
let mut ids = HashSet::new();
|
||||
for row in rows {
|
||||
let (id, source, thread_source) = row.map_err(database_error)?;
|
||||
if source.as_deref().is_some_and(source_text_is_subagent)
|
||||
|| thread_source
|
||||
.as_deref()
|
||||
.is_some_and(thread_source_is_internal)
|
||||
{
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub(super) fn scan_catalog_internal_thread_ids(paths: &[PathBuf]) -> Result<HashSet<String>> {
|
||||
let mut ids = HashSet::new();
|
||||
for path in paths {
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
let columns = catalog_columns(&conn)?;
|
||||
ids.extend(catalog_internal_thread_ids(&conn, &columns)?);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn collect_catalog_repair_threads(
|
||||
paths: &[PathBuf],
|
||||
target_provider: &str,
|
||||
@@ -100,6 +156,7 @@ fn collect_catalog_repair_threads(
|
||||
if !columns.contains("id") {
|
||||
continue;
|
||||
}
|
||||
let internal_ids = sqlite_subagent_thread_ids(&conn, &columns)?;
|
||||
let display_title = coalesce_text_expr(
|
||||
&columns,
|
||||
&["name", "title", "preview", "first_user_message"],
|
||||
@@ -150,7 +207,7 @@ fn collect_catalog_repair_threads(
|
||||
.map_err(database_error)?;
|
||||
for row in rows {
|
||||
let thread = row.map_err(database_error)?;
|
||||
if !syncable_thread_ids.contains(&thread.id) {
|
||||
if !syncable_thread_ids.contains(&thread.id) || internal_ids.contains(&thread.id) {
|
||||
continue;
|
||||
}
|
||||
let replace = threads
|
||||
@@ -238,8 +295,13 @@ pub(super) fn scan_catalog_sync(
|
||||
target_provider: &str,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogSyncScan> {
|
||||
let internal_ids = scan_catalog_internal_thread_ids(catalog_paths)?;
|
||||
let syncable_thread_ids: HashSet<String> = syncable_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let sources =
|
||||
collect_catalog_repair_threads(thread_paths, target_provider, syncable_thread_ids)?;
|
||||
collect_catalog_repair_threads(thread_paths, target_provider, &syncable_thread_ids)?;
|
||||
let mut scan = CatalogSyncScan {
|
||||
sources,
|
||||
..CatalogSyncScan::default()
|
||||
@@ -680,18 +742,35 @@ pub(super) fn apply_catalog_updates(
|
||||
sources: &HashMap<String, CatalogRepairThread>,
|
||||
provider_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogUpdateCounts> {
|
||||
let internal_ids = catalog_internal_thread_ids(conn, columns)?;
|
||||
let provider_thread_ids: HashSet<String> = provider_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let sources = sources
|
||||
.iter()
|
||||
.filter(|(id, thread)| {
|
||||
provider_thread_ids.contains(*id)
|
||||
&& !source_text_is_subagent(&thread.source_kind)
|
||||
&& !thread
|
||||
.thread_source
|
||||
.as_deref()
|
||||
.is_some_and(thread_source_is_internal)
|
||||
})
|
||||
.map(|(id, thread)| (id.clone(), thread.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut counts = CatalogUpdateCounts::default();
|
||||
let repair_host = if !sources.is_empty() && catalog_supports_repair(columns) {
|
||||
Some(local_catalog_host_id(conn)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider, provider_thread_ids)?;
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider, &provider_thread_ids)?;
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
snapshot_hidden_catalog_sources(conn, columns, host_id, sources)?;
|
||||
snapshot_hidden_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
}
|
||||
if columns.contains("model_provider") && columns.contains("thread_id") {
|
||||
for thread_id in provider_thread_ids {
|
||||
for thread_id in &provider_thread_ids {
|
||||
conn.execute(
|
||||
"UPDATE local_thread_catalog SET model_provider = ?1 \
|
||||
WHERE thread_id = ?2 AND COALESCE(model_provider, '') <> ?1",
|
||||
@@ -701,9 +780,9 @@ pub(super) fn apply_catalog_updates(
|
||||
}
|
||||
}
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
reactivate_catalog_sources(conn, columns, host_id, sources)?;
|
||||
reactivate_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
let (inserted, observation_sequence, max_source_updated_at) =
|
||||
insert_missing_catalog_sources(conn, columns, host_id, sources)?;
|
||||
insert_missing_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
counts.inserted_rows = inserted;
|
||||
if inserted > 0 {
|
||||
update_catalog_sync_state(conn, host_id, observation_sequence, max_source_updated_at)?;
|
||||
@@ -1114,4 +1193,30 @@ mod tests {
|
||||
("openai".to_string(), 1, 7)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn internal_catalog_rows_and_sources_ignore_even_explicit_sync_candidates() {
|
||||
let conn = Connection::open_in_memory().expect("open internal catalog fixture");
|
||||
create_catalog_schema(&conn);
|
||||
insert_catalog_row(&conn, "hidden-review", "openai", 1);
|
||||
conn.execute("UPDATE local_thread_catalog SET thread_source = 'guardian_review' WHERE thread_id = 'hidden-review'", [])
|
||||
.expect("mark internal catalog source");
|
||||
let mut internal_source = repair_source("missing-review", "custom");
|
||||
internal_source.source_kind = r#"{"internal":"guardian"}"#.to_string();
|
||||
internal_source.thread_source = None;
|
||||
let sources = HashMap::from([(internal_source.id.clone(), internal_source)]);
|
||||
let candidates = HashSet::from(["hidden-review".to_string(), "missing-review".to_string()]);
|
||||
let counts = apply_and_commit(&conn, "custom", &sources, &candidates);
|
||||
assert_eq!(counts.provider_rows, 0);
|
||||
assert_eq!(counts.inserted_rows, 0);
|
||||
assert_eq!(
|
||||
catalog_state(&conn, "hidden-review"),
|
||||
("openai".to_string(), 1, 7)
|
||||
);
|
||||
let rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM local_thread_catalog", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.expect("count preserved catalog");
|
||||
assert_eq!(rows, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
use super::app_server::delete_sessions_via_codex_app_server;
|
||||
use super::rollout_stream::{for_each_jsonl_record, record_matches_top_level_strings};
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
|
||||
split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
|
||||
sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
|
||||
SqliteThreadIndexState,
|
||||
};
|
||||
use super::sync::{acquire_session_maintenance_lock, session_sync_status_with_discovery};
|
||||
use super::types::SessionSyncStatus;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{io_err, write_text};
|
||||
use crate::file_io::io_err;
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set};
|
||||
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::storage::hash_rollout_file;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -481,88 +486,447 @@ fn selected_rollout_paths(
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
const CLEANUP_IO_BUFFER_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct CleanupFileStamp {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
created: Option<SystemTime>,
|
||||
unix_identity: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl CleanupFileStamp {
|
||||
fn from_metadata(metadata: &fs::Metadata) -> Self {
|
||||
#[cfg(unix)]
|
||||
let unix_identity = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Some((metadata.dev(), metadata.ino()))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let unix_identity = None;
|
||||
Self {
|
||||
len: metadata.len(),
|
||||
modified: metadata.modified().ok(),
|
||||
created: metadata.created().ok(),
|
||||
unix_identity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CleanupHashingReader<'a> {
|
||||
source: &'a mut fs::File,
|
||||
backup: Option<&'a mut fs::File>,
|
||||
digest: &'a mut Sha256,
|
||||
consumed: u64,
|
||||
}
|
||||
|
||||
impl Read for CleanupHashingReader<'_> {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
|
||||
let count = self.source.read(bytes)?;
|
||||
if let Some(backup) = self.backup.as_mut() {
|
||||
backup.write_all(&bytes[..count])?;
|
||||
}
|
||||
self.digest.update(&bytes[..count]);
|
||||
self.consumed += count as u64;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_cleanup_bytes(reader: &mut impl Read, writer: &mut impl Write) -> std::io::Result<u64> {
|
||||
let mut bytes = [0u8; CLEANUP_IO_BUFFER_BYTES];
|
||||
let mut copied = 0u64;
|
||||
loop {
|
||||
let count = reader.read(&mut bytes)?;
|
||||
if count == 0 {
|
||||
return Ok(copied);
|
||||
}
|
||||
writer.write_all(&bytes[..count])?;
|
||||
copied += count as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_cleanup_records<R: Read, W: Write>(
|
||||
reader: R,
|
||||
writer: &mut W,
|
||||
path: &Path,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let mut removed = 0usize;
|
||||
for_each_jsonl_record(reader, |record, _| {
|
||||
record
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let matches = if session_ids.is_empty() {
|
||||
false
|
||||
} else {
|
||||
match record_matches_top_level_strings(record, id_keys, session_ids, 128) {
|
||||
Ok(matches) => matches,
|
||||
Err(error @ CodexxError::Io { .. }) => return Err(error),
|
||||
// Invalid JSON is preserved exactly as in the previous filter.
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
if matches {
|
||||
removed += 1;
|
||||
} else {
|
||||
record
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
copy_cleanup_bytes(record, writer).map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
struct PreparedJsonlCleanup {
|
||||
output: NamedTempFile,
|
||||
original_backup: Option<NamedTempFile>,
|
||||
original_stamp: CleanupFileStamp,
|
||||
original_hash: [u8; 32],
|
||||
removed: usize,
|
||||
}
|
||||
|
||||
fn cleanup_source_changed(path: &Path) -> CodexxError {
|
||||
CodexxError::Config(format!(
|
||||
"会话索引或历史记录在清理期间发生变化,已取消替换: {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn hash_open_cleanup_file(path: &Path, source: &fs::File) -> Result<[u8; 32]> {
|
||||
// Read the existing handle: an exclusive history lock can reject a second
|
||||
// handle's read on Windows, even when it is opened by this same process.
|
||||
let mut reader = source;
|
||||
reader
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
|
||||
loop {
|
||||
let count = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
return Ok(digest.finalize().into());
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_cleanup_source_unchanged(
|
||||
path: &Path,
|
||||
source: &fs::File,
|
||||
stamp: &CleanupFileStamp,
|
||||
hash: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
let source_stamp = || -> Result<CleanupFileStamp> {
|
||||
source
|
||||
.metadata()
|
||||
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
|
||||
.map_err(|error| io_err(path, error))
|
||||
};
|
||||
let path_stamp = || -> Result<CleanupFileStamp> {
|
||||
fs::metadata(path)
|
||||
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
|
||||
.map_err(|error| io_err(path, error))
|
||||
};
|
||||
if &source_stamp()? != stamp || &path_stamp()? != stamp {
|
||||
return Err(cleanup_source_changed(path));
|
||||
}
|
||||
if &hash_open_cleanup_file(path, source)? != hash
|
||||
|| &source_stamp()? != stamp
|
||||
|| &path_stamp()? != stamp
|
||||
{
|
||||
return Err(cleanup_source_changed(path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_jsonl_cleanup(
|
||||
path: &Path,
|
||||
source: &mut fs::File,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
retain_backup: bool,
|
||||
) -> Result<PreparedJsonlCleanup> {
|
||||
let metadata = source.metadata().map_err(|error| io_err(path, error))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话日志不是普通文件: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let original_stamp = CleanupFileStamp::from_metadata(&metadata);
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut output = NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?;
|
||||
let mut original_backup = if retain_backup {
|
||||
Some(NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut reader = CleanupHashingReader {
|
||||
source: &mut *source,
|
||||
backup: original_backup.as_mut().map(NamedTempFile::as_file_mut),
|
||||
digest: &mut digest,
|
||||
consumed: 0,
|
||||
};
|
||||
let removed = filter_cleanup_records(
|
||||
&mut reader,
|
||||
output.as_file_mut(),
|
||||
path,
|
||||
id_keys,
|
||||
session_ids,
|
||||
)?;
|
||||
let consumed = reader.consumed;
|
||||
drop(reader);
|
||||
if consumed != original_stamp.len
|
||||
|| CleanupFileStamp::from_metadata(&source.metadata().map_err(|error| io_err(path, error))?)
|
||||
!= original_stamp
|
||||
{
|
||||
return Err(cleanup_source_changed(path));
|
||||
}
|
||||
output
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if let Some(backup) = original_backup.as_ref() {
|
||||
backup
|
||||
.as_file()
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
let original_hash = digest.finalize().into();
|
||||
ensure_cleanup_source_unchanged(path, source, &original_stamp, &original_hash)?;
|
||||
Ok(PreparedJsonlCleanup {
|
||||
output,
|
||||
original_backup,
|
||||
original_stamp,
|
||||
original_hash,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
|
||||
fn preflight_session_jsonl_cleanup(codex_dir: &Path) -> Result<()> {
|
||||
// Framing and staging use fixed memory even for a very large valid history.
|
||||
// Read/write failures still occur before the irreversible deletion API.
|
||||
for filename in ["session_index.jsonl", "history.jsonl"] {
|
||||
let path = codex_dir.join(filename);
|
||||
let mut source = match fs::File::open(&path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
prepare_jsonl_cleanup(&path, &mut source, &[], &HashSet::new(), false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit_index_cleanup(
|
||||
path: &Path,
|
||||
source: &fs::File,
|
||||
prepared: PreparedJsonlCleanup,
|
||||
) -> Result<usize> {
|
||||
if prepared.removed == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
ensure_cleanup_source_unchanged(
|
||||
path,
|
||||
source,
|
||||
&prepared.original_stamp,
|
||||
&prepared.original_hash,
|
||||
)?;
|
||||
let permissions = source
|
||||
.metadata()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.permissions();
|
||||
prepared
|
||||
.output
|
||||
.as_file()
|
||||
.set_permissions(permissions)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
prepared
|
||||
.output
|
||||
.persist(path)
|
||||
.map_err(|error| io_err(path, error.error))?;
|
||||
Ok(prepared.removed)
|
||||
}
|
||||
|
||||
fn remove_jsonl_session_entries(
|
||||
path: &Path,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let (next, removed) = filter_jsonl_session_entries(&text, id_keys, session_ids);
|
||||
if removed > 0 {
|
||||
write_text(path, &next)?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn filter_jsonl_session_entries(
|
||||
text: &str,
|
||||
id_keys: &[&str],
|
||||
session_ids: &HashSet<String>,
|
||||
) -> (String, usize) {
|
||||
let mut next = String::with_capacity(text.len());
|
||||
let mut removed = 0usize;
|
||||
for segment in text.split_inclusive('\n') {
|
||||
let (line, ending) = split_line_ending(segment);
|
||||
let matches = serde_json::from_str::<Value>(line)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
id_keys.iter().find_map(|key| {
|
||||
value
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
})
|
||||
.is_some_and(|id| session_ids.contains(&id));
|
||||
if matches {
|
||||
removed += 1;
|
||||
} else {
|
||||
next.push_str(line);
|
||||
next.push_str(ending);
|
||||
}
|
||||
}
|
||||
(next, removed)
|
||||
let mut source = match fs::File::open(path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
let prepared = prepare_jsonl_cleanup(path, &mut source, id_keys, session_ids, false)?;
|
||||
commit_index_cleanup(path, &source, prepared)
|
||||
}
|
||||
|
||||
fn remove_session_index_entries(codex_dir: &Path, session_ids: &HashSet<String>) -> Result<usize> {
|
||||
remove_jsonl_session_entries(&codex_dir.join("session_index.jsonl"), &["id"], session_ids)
|
||||
}
|
||||
|
||||
fn restore_history_backup(
|
||||
path: &Path,
|
||||
source: &mut fs::File,
|
||||
backup: &mut fs::File,
|
||||
original_len: u64,
|
||||
original_hash: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
backup
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if copy_cleanup_bytes(backup, source).map_err(|error| io_err(path, error))? != original_len {
|
||||
return Err(CodexxError::Config(
|
||||
"历史记录备份不完整,无法安全回滚".into(),
|
||||
));
|
||||
}
|
||||
// Filtering never grows the file. Replaying the complete backup restores its
|
||||
// original length after a failed truncate, and does not truncate a later
|
||||
// append beyond the original EOF from a writer that ignored the lock.
|
||||
source.sync_all().map_err(|error| io_err(path, error))?;
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
|
||||
let mut remaining = original_len;
|
||||
while remaining > 0 {
|
||||
let allowed = buffer.len().min(remaining as usize);
|
||||
let count = source
|
||||
.read(&mut buffer[..allowed])
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
return Err(CodexxError::Config("历史记录回滚后长度不足".into()));
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
remaining -= count as u64;
|
||||
}
|
||||
let restored: [u8; 32] = digest.finalize().into();
|
||||
if &restored != original_hash {
|
||||
return Err(CodexxError::Config("历史记录回滚校验失败".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit_history_cleanup_with_writer<F>(
|
||||
path: &Path,
|
||||
source: &mut fs::File,
|
||||
mut prepared: PreparedJsonlCleanup,
|
||||
write_output: F,
|
||||
) -> Result<usize>
|
||||
where
|
||||
F: FnOnce(&mut fs::File, &mut fs::File, u64, u64) -> Result<()>,
|
||||
{
|
||||
if prepared.removed == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
ensure_cleanup_source_unchanged(
|
||||
path,
|
||||
source,
|
||||
&prepared.original_stamp,
|
||||
&prepared.original_hash,
|
||||
)?;
|
||||
let output_len = prepared
|
||||
.output
|
||||
.as_file()
|
||||
.metadata()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.len();
|
||||
if output_len > prepared.original_stamp.len {
|
||||
return Err(CodexxError::Config(
|
||||
"历史记录过滤结果异常,未写入原文件".into(),
|
||||
));
|
||||
}
|
||||
let mut backup = prepared
|
||||
.original_backup
|
||||
.take()
|
||||
.ok_or_else(|| CodexxError::Config("历史记录缺少完整磁盘备份,未写入原文件".into()))?;
|
||||
if let Err(error) = write_output(
|
||||
source,
|
||||
prepared.output.as_file_mut(),
|
||||
output_len,
|
||||
prepared.original_stamp.len,
|
||||
) {
|
||||
if let Err(restore_error) = restore_history_backup(
|
||||
path,
|
||||
source,
|
||||
backup.as_file_mut(),
|
||||
prepared.original_stamp.len,
|
||||
&prepared.original_hash,
|
||||
) {
|
||||
return match backup.keep() {
|
||||
Ok((_, retained)) => Err(CodexxError::Config(format!(
|
||||
"{error};历史记录回滚失败:{restore_error};已保留私有备份:{}",
|
||||
retained.display()
|
||||
))),
|
||||
Err(keep_error) => Err(CodexxError::Config(format!(
|
||||
"{error};历史记录回滚失败:{restore_error};保留备份失败:{keep_error}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(prepared.removed)
|
||||
}
|
||||
|
||||
fn remove_session_history_entries(
|
||||
codex_dir: &Path,
|
||||
session_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let path = codex_dir.join("history.jsonl");
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
let mut file = match fs::OpenOptions::new().read(true).write(true).open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
file.try_lock().map_err(|error| {
|
||||
CodexxError::Config(format!(
|
||||
"历史记录正在被其他 Codex 进程使用,请关闭相关 Codex 窗口或 CLI 后重试: {error}"
|
||||
))
|
||||
})?;
|
||||
let result = (|| -> Result<usize> {
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
let (next, removed) = filter_jsonl_session_entries(&text, &["session_id"], session_ids);
|
||||
if removed > 0 {
|
||||
file.set_len(0).map_err(|e| io_err(&path, e))?;
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
file.write_all(next.as_bytes())
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&path, e))?;
|
||||
}
|
||||
Ok(removed)
|
||||
let prepared = prepare_jsonl_cleanup(&path, &mut file, &["session_id"], session_ids, true)?;
|
||||
commit_history_cleanup_with_writer(
|
||||
&path,
|
||||
&mut file,
|
||||
prepared,
|
||||
|source, output, output_len, original_len| {
|
||||
output
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
source
|
||||
.seek(SeekFrom::Start(0))
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
copy_cleanup_bytes(output, source).map_err(|error| io_err(&path, error))?;
|
||||
if source
|
||||
.metadata()
|
||||
.map_err(|error| io_err(&path, error))?
|
||||
.len()
|
||||
!= original_len
|
||||
{
|
||||
return Err(cleanup_source_changed(&path));
|
||||
}
|
||||
source
|
||||
.set_len(output_len)
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
source.sync_all().map_err(|error| io_err(&path, error))?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
})();
|
||||
let _ = file.unlock();
|
||||
result
|
||||
@@ -787,6 +1151,7 @@ pub(crate) fn hard_delete_sessions_locally(
|
||||
let relationship_sources = relationship_database_sources(&discovery, roots)?;
|
||||
let session_ids = session_ids_with_descendants(&relationship_sources, roots)?;
|
||||
let rollout_paths = selected_rollout_paths(codex_dir, &discovery.thread_paths, &session_ids)?;
|
||||
preflight_session_jsonl_cleanup(codex_dir)?;
|
||||
Ok(delete_exact_session_ids_locally(
|
||||
codex_dir,
|
||||
&discovery.related_paths,
|
||||
@@ -834,6 +1199,7 @@ pub(crate) fn delete_codex_sessions_inner(
|
||||
// make the deletion irreversible.
|
||||
let expected_rollout_paths =
|
||||
selected_rollout_paths(&codex_dir, &discovery.thread_paths, &expected_ids)?;
|
||||
preflight_session_jsonl_cleanup(&codex_dir)?;
|
||||
let mut counts = LocalSessionDeleteCounts::default();
|
||||
let mut failed_roots = Vec::new();
|
||||
|
||||
@@ -1121,4 +1487,417 @@ mod tests {
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_cleanup_logs_cancel_native_and_local_deletion_before_any_mutation() {
|
||||
for filename in ["session_index.jsonl", "history.jsonl"] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let id = "019f6000-0000-7000-8000-000000000371";
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id);
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
|
||||
let rollout_text = format!(
|
||||
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n"
|
||||
);
|
||||
fs::write(&rollout, &rollout_text).unwrap();
|
||||
for (name, key) in [
|
||||
("session_index.jsonl", "id"),
|
||||
("history.jsonl", "session_id"),
|
||||
] {
|
||||
fs::write(codex_dir.join(name), format!("{{\"{key}\":\"{id}\"}}\n")).unwrap();
|
||||
}
|
||||
let unreadable = codex_dir.join(filename);
|
||||
fs::remove_file(&unreadable).unwrap();
|
||||
fs::create_dir(&unreadable).unwrap();
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let untouched_name = if filename == "history.jsonl" {
|
||||
"session_index.jsonl"
|
||||
} else {
|
||||
"history.jsonl"
|
||||
};
|
||||
let untouched_path = codex_dir.join(untouched_name);
|
||||
let untouched_before = fs::read(&untouched_path).unwrap();
|
||||
|
||||
let local_error = hard_delete_sessions_locally(&codex_dir, &[id.to_string()])
|
||||
.expect_err("local deletion must reject an unreadable cleanup log");
|
||||
assert!(local_error.to_string().contains(filename));
|
||||
let native_error = delete_codex_sessions_inner(SessionDeleteInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
session_ids: vec![id.to_string()],
|
||||
})
|
||||
.expect_err("native deletion must stop at preflight before starting Codex");
|
||||
assert!(native_error.to_string().contains(filename));
|
||||
|
||||
assert_eq!(fs::read(&rollout).unwrap(), rollout_text.as_bytes());
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&untouched_path).unwrap(), untouched_before);
|
||||
assert!(unreadable.is_dir());
|
||||
let present =
|
||||
active_session_ids_present(&[database], &HashSet::from([id.to_string()])).unwrap();
|
||||
assert!(present.contains(id));
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_cleanup_logs_stream_without_a_total_size_limit() {
|
||||
for filename in ["session_index.jsonl", "history.jsonl"] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let id = "019f6000-0000-7000-8000-000000000381";
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id);
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
|
||||
fs::write(&rollout, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n")).unwrap();
|
||||
let path = codex_dir.join(filename);
|
||||
let key = if filename == "history.jsonl" {
|
||||
"session_id"
|
||||
} else {
|
||||
"id"
|
||||
};
|
||||
let remove_large_record = filename == "history.jsonl";
|
||||
let mut file = fs::File::create(&path).unwrap();
|
||||
let large_id = if remove_large_record { id } else { "kept" };
|
||||
let prefix = format!("{{\"{key}\":\"{large_id}\",\"text\":\"");
|
||||
let mut expected = Sha256::new();
|
||||
file.write_all(prefix.as_bytes()).unwrap();
|
||||
if !remove_large_record {
|
||||
expected.update(prefix.as_bytes());
|
||||
}
|
||||
let bytes = [b'x'; CLEANUP_IO_BUFFER_BYTES];
|
||||
for _ in 0..(33 * 1024 * 1024 / CLEANUP_IO_BUFFER_BYTES) {
|
||||
file.write_all(&bytes).unwrap();
|
||||
if !remove_large_record {
|
||||
expected.update(bytes);
|
||||
}
|
||||
}
|
||||
file.write_all(b"\"}\r\n").unwrap();
|
||||
if !remove_large_record {
|
||||
expected.update(b"\"}\r\n");
|
||||
file.write_all(format!("{{\"{key}\":\"{id}\"}}\n").as_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
let tail = format!("not-json\r\n{{\"{key}\":\"{id}\",\"{key}\":\"kept-last\"}}\n{{\"{key}\":\"kept-final\"}}");
|
||||
file.write_all(tail.as_bytes()).unwrap();
|
||||
expected.update(tail.as_bytes());
|
||||
drop(file);
|
||||
assert!(fs::metadata(&path).unwrap().len() > 32 * 1024 * 1024);
|
||||
let other_name = if filename == "history.jsonl" {
|
||||
"session_index.jsonl"
|
||||
} else {
|
||||
"history.jsonl"
|
||||
};
|
||||
let other_key = if key == "id" { "session_id" } else { "id" };
|
||||
fs::write(
|
||||
codex_dir.join(other_name),
|
||||
format!("{{\"{other_key}\":\"{id}\"}}\r\n{{\"{other_key}\":\"untouched\"}}"),
|
||||
)
|
||||
.unwrap();
|
||||
preflight_session_jsonl_cleanup(&codex_dir)
|
||||
.expect("large valid logs must pass streaming preflight");
|
||||
let result = hard_delete_sessions_locally(&codex_dir, &[id.to_string()]).unwrap();
|
||||
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||
assert!(!rollout.exists());
|
||||
assert!(
|
||||
!active_session_ids_present(&[database], &HashSet::from([id.to_string()]))
|
||||
.unwrap()
|
||||
.contains(id)
|
||||
);
|
||||
let expected_hash: [u8; 32] = expected.finalize().into();
|
||||
assert_eq!(hash_rollout_file(&path).unwrap(), expected_hash);
|
||||
assert_eq!(
|
||||
fs::read(codex_dir.join(other_name)).unwrap(),
|
||||
format!("{{\"{other_key}\":\"untouched\"}}").as_bytes()
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_cleanup_preserves_missing_files_and_normal_jsonl_filtering() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
|
||||
assert!(!codex_dir.join("session_index.jsonl").exists());
|
||||
assert!(!codex_dir.join("history.jsonl").exists());
|
||||
let ids = HashSet::from(["selected".to_string()]);
|
||||
for (filename, key) in [
|
||||
("session_index.jsonl", "id"),
|
||||
("history.jsonl", "session_id"),
|
||||
] {
|
||||
fs::write(
|
||||
codex_dir.join(filename),
|
||||
format!("{{\"{key}\":\"selected\"}}\r\nnot-json\r\n{{\"{key}\":\"kept\"}}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
|
||||
assert_eq!(remove_session_index_entries(&codex_dir, &ids).unwrap(), 1);
|
||||
assert_eq!(remove_session_history_entries(&codex_dir, &ids).unwrap(), 1);
|
||||
for (filename, key) in [
|
||||
("session_index.jsonl", "id"),
|
||||
("history.jsonl", "session_id"),
|
||||
] {
|
||||
assert_eq!(
|
||||
fs::read_to_string(codex_dir.join(filename)).unwrap(),
|
||||
format!("not-json\r\n{{\"{key}\":\"kept\"}}")
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_cleanup_only_matches_valid_top_level_ids_and_preserves_other_bytes() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("session_index.jsonl");
|
||||
let kept = b"not-json\r\n{\"nested\":{\"id\":\"selected\"},\"id\":\"kept\"}\n{\"id\":\"selected\",\"id\":null}\r\n\xff\n";
|
||||
let mut original = kept.to_vec();
|
||||
original.extend_from_slice(b"{\"id\":\"selected\"}\r\n");
|
||||
original.extend_from_slice(b"{\"id\":\"last-kept\"}");
|
||||
fs::write(&path, original).unwrap();
|
||||
assert_eq!(
|
||||
remove_session_index_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let mut expected = kept.to_vec();
|
||||
expected.extend_from_slice(b"{\"id\":\"last-kept\"}");
|
||||
assert_eq!(fs::read(path).unwrap(), expected);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
struct FailingCleanupRead<R> {
|
||||
inner: R,
|
||||
remaining: usize,
|
||||
}
|
||||
impl<R: Read> Read for FailingCleanupRead<R> {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.remaining == 0 {
|
||||
return Err(std::io::Error::other("injected cleanup read failure"));
|
||||
}
|
||||
let allowed = bytes.len().min(self.remaining);
|
||||
let count = self.inner.read(&mut bytes[..allowed])?;
|
||||
self.remaining -= count;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingCleanupWrite<W> {
|
||||
inner: W,
|
||||
remaining: usize,
|
||||
}
|
||||
impl<W: Write> Write for FailingCleanupWrite<W> {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if self.remaining == 0 {
|
||||
return Err(std::io::Error::other("injected cleanup write failure"));
|
||||
}
|
||||
let count = self
|
||||
.inner
|
||||
.write(&bytes[..bytes.len().min(self.remaining)])?;
|
||||
self.remaining -= count;
|
||||
Ok(count)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_cleanup_read_or_staging_write_failure_preserves_the_source() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("session_index.jsonl");
|
||||
let original = b"{\"id\":\"kept\",\"text\":\"retained\"}\r\n{\"id\":\"selected\"}\n";
|
||||
fs::write(&path, original).unwrap();
|
||||
let ids = HashSet::from(["selected".to_string()]);
|
||||
let mut target = NamedTempFile::new_in(&codex_dir).unwrap();
|
||||
let error = filter_cleanup_records(
|
||||
FailingCleanupRead {
|
||||
inner: fs::File::open(&path).unwrap(),
|
||||
remaining: 7,
|
||||
},
|
||||
target.as_file_mut(),
|
||||
&path,
|
||||
&["id"],
|
||||
&ids,
|
||||
)
|
||||
.expect_err("read errors must propagate, not become a malformed record");
|
||||
assert!(matches!(error, CodexxError::Io { .. }));
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
let mut writer = FailingCleanupWrite {
|
||||
inner: target.as_file_mut(),
|
||||
remaining: 3,
|
||||
};
|
||||
let error = filter_cleanup_records(
|
||||
fs::File::open(&path).unwrap(),
|
||||
&mut writer,
|
||||
&path,
|
||||
&["id"],
|
||||
&ids,
|
||||
)
|
||||
.expect_err("a failed private output must never be installed");
|
||||
assert!(matches!(error, CodexxError::Io { .. }));
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
drop(target);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_cleanup_detects_growth_before_index_or_history_commit() {
|
||||
for history in [false, true] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join(if history {
|
||||
"history.jsonl"
|
||||
} else {
|
||||
"session_index.jsonl"
|
||||
});
|
||||
let key = if history { "session_id" } else { "id" };
|
||||
fs::write(
|
||||
&path,
|
||||
format!("{{\"{key}\":\"selected\"}}\n{{\"{key}\":\"kept\"}}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut source = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
let prepared = prepare_jsonl_cleanup(
|
||||
&path,
|
||||
&mut source,
|
||||
&[key],
|
||||
&HashSet::from(["selected".to_string()]),
|
||||
history,
|
||||
)
|
||||
.unwrap();
|
||||
let mut external = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
external
|
||||
.write_all(format!("{{\"{key}\":\"new-append\"}}\n").as_bytes())
|
||||
.unwrap();
|
||||
external.sync_all().unwrap();
|
||||
drop(external);
|
||||
let grown = fs::read(&path).unwrap();
|
||||
let result = if history {
|
||||
commit_history_cleanup_with_writer(&path, &mut source, prepared, |_, _, _, _| {
|
||||
panic!("growth must be rejected before writing history")
|
||||
})
|
||||
} else {
|
||||
commit_index_cleanup(&path, &source, prepared)
|
||||
};
|
||||
assert!(result
|
||||
.expect_err("a stale staged result must not replace appended data")
|
||||
.to_string()
|
||||
.contains("发生变化"));
|
||||
assert_eq!(fs::read(&path).unwrap(), grown);
|
||||
drop(source);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_commit_write_failure_restores_backup_and_keeps_its_lock_and_inode() {
|
||||
for fail_after_truncate in [false, true] {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("history.jsonl");
|
||||
let original = b"{\"session_id\":\"selected\",\"text\":\"private fixture\"}\r\n{\"session_id\":\"kept\"}";
|
||||
fs::write(&path, original).unwrap();
|
||||
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
|
||||
let mut source = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
source.try_lock().unwrap();
|
||||
let probe = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
assert!(probe.try_lock().is_err());
|
||||
let prepared = prepare_jsonl_cleanup(
|
||||
&path,
|
||||
&mut source,
|
||||
&["session_id"],
|
||||
&HashSet::from(["selected".to_string()]),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let error = commit_history_cleanup_with_writer(
|
||||
&path,
|
||||
&mut source,
|
||||
prepared,
|
||||
|source, output, output_len, _| {
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
output.seek(SeekFrom::Start(0)).unwrap();
|
||||
if fail_after_truncate {
|
||||
copy_cleanup_bytes(output, source).unwrap();
|
||||
source.set_len(output_len).unwrap();
|
||||
} else {
|
||||
source.write_all(b"partial failed write").unwrap();
|
||||
}
|
||||
Err(io_err(
|
||||
&path,
|
||||
std::io::Error::other("injected commit write/sync failure"),
|
||||
))
|
||||
},
|
||||
)
|
||||
.expect_err("failed commit must report the failure after restoring the disk backup");
|
||||
assert!(error.to_string().contains("injected commit"));
|
||||
let expected_hash: [u8; 32] = Sha256::digest(original).into();
|
||||
assert_eq!(
|
||||
hash_open_cleanup_file(&path, &source).unwrap(),
|
||||
expected_hash
|
||||
);
|
||||
assert_eq!(source.metadata().unwrap().len(), original.len() as u64);
|
||||
assert_eq!(
|
||||
CleanupFileStamp::from_metadata(&source.metadata().unwrap()).unix_identity,
|
||||
before.unix_identity
|
||||
);
|
||||
assert!(
|
||||
probe.try_lock().is_err(),
|
||||
"rollback must not release the live history lock"
|
||||
);
|
||||
source.unlock().unwrap();
|
||||
assert!(probe.try_lock().is_ok());
|
||||
probe.unlock().unwrap();
|
||||
drop(probe);
|
||||
drop(source);
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_history_cleanup_keeps_an_already_open_append_handle_usable() {
|
||||
let codex_dir = temp_codex_dir();
|
||||
let path = codex_dir.join("history.jsonl");
|
||||
fs::write(
|
||||
&path,
|
||||
b"{\"session_id\":\"selected\"}\n{\"session_id\":\"kept\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
|
||||
let mut old_append_handle = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
remove_session_history_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
old_append_handle.try_lock().unwrap();
|
||||
old_append_handle
|
||||
.write_all(b"{\"session_id\":\"later\"}\r\n")
|
||||
.unwrap();
|
||||
old_append_handle.unlock().unwrap();
|
||||
drop(old_append_handle);
|
||||
let after = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
|
||||
assert_eq!(after.unix_identity, before.unix_identity);
|
||||
assert_eq!(
|
||||
fs::read(&path).unwrap(),
|
||||
b"{\"session_id\":\"kept\"}\n{\"session_id\":\"later\"}\r\n"
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
use super::types::SessionPreview;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct DirectoryIdentity {
|
||||
key: String,
|
||||
birth_verified: bool,
|
||||
}
|
||||
|
||||
fn filesystem_identity(
|
||||
platform: &[u8],
|
||||
parts: &[&[u8]],
|
||||
birth: Option<SystemTime>,
|
||||
) -> DirectoryIdentity {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x/session-directory-identity/v1\0");
|
||||
digest.update(platform);
|
||||
digest.update(b"\0");
|
||||
for part in parts {
|
||||
digest.update(part);
|
||||
}
|
||||
if let Some(birth) = birth {
|
||||
digest.update(b"\0birth\0");
|
||||
let (sign, age) = match birth.duration_since(UNIX_EPOCH) {
|
||||
Ok(age) => (b'+', age),
|
||||
Err(before_epoch) => (b'-', before_epoch.duration()),
|
||||
};
|
||||
digest.update([sign]);
|
||||
digest.update(age.as_secs().to_le_bytes());
|
||||
digest.update(age.subsec_nanos().to_le_bytes());
|
||||
} else {
|
||||
digest.update(b"\0no-birth\0");
|
||||
}
|
||||
DirectoryIdentity {
|
||||
key: format!("filesystem:{:x}", digest.finalize()),
|
||||
birth_verified: birth.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn unix_directory_identity(
|
||||
device: u64,
|
||||
inode: u64,
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// Some virtual or unsupported filesystems expose no usable inode number.
|
||||
if inode == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"unix",
|
||||
&[&device.to_le_bytes(), &inode.to_le_bytes()],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_directory_identity(
|
||||
volume: u64,
|
||||
identifier: &[u8; 16],
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// The complete 128-bit ID is needed for ReFS; the old 64-bit file index
|
||||
// cannot safely distinguish all directories on that filesystem.
|
||||
if identifier.iter().all(|byte| *byte == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"windows",
|
||||
&[&volume.to_le_bytes(), identifier],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let handle = File::open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
unix_directory_identity(metadata.dev(), metadata.ino(), metadata.created().ok())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "directory has no stable inode"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FileIdInfo, GetFileInformationByHandleEx, FILE_FLAG_BACKUP_SEMANTICS, FILE_ID_INFO,
|
||||
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
// READ_ATTRIBUTES plus BACKUP_SEMANTICS opens directories without requiring
|
||||
// permission to list them. No content is read and no filesystem state is changed.
|
||||
let handle = OpenOptions::new()
|
||||
.read(true)
|
||||
.access_mode(FILE_READ_ATTRIBUTES)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
|
||||
.open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
let mut info = FILE_ID_INFO::default();
|
||||
// The owned read-only handle stays alive while Windows fills this exact
|
||||
// FILE_ID_INFO buffer. Unsupported filesystems return an error and fall back.
|
||||
let succeeded = unsafe {
|
||||
GetFileInformationByHandleEx(
|
||||
handle.as_raw_handle(),
|
||||
FileIdInfo,
|
||||
(&mut info as *mut FILE_ID_INFO).cast(),
|
||||
std::mem::size_of::<FILE_ID_INFO>() as u32,
|
||||
)
|
||||
};
|
||||
if succeeded == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
windows_directory_identity(
|
||||
info.VolumeSerialNumber,
|
||||
&info.FileId.Identifier,
|
||||
metadata.created().ok(),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory has no stable file ID",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn native_directory_identity(_path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory identity is unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
fn unresolved_directory_identity(path: &str) -> DirectoryIdentity {
|
||||
DirectoryIdentity {
|
||||
key: format!("path:{path}"),
|
||||
birth_verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory_lookup(path: &str) -> DirectoryIdentity {
|
||||
let filesystem_path = Path::new(path);
|
||||
// Historical relative cwd values cannot be resolved against this app's
|
||||
// process cwd. Check the path kind before opening, including FIFO/device
|
||||
// values that must never be treated as a workspace directory.
|
||||
if !filesystem_path.is_absolute()
|
||||
|| !std::fs::metadata(filesystem_path).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
return unresolved_directory_identity(path);
|
||||
}
|
||||
native_directory_identity(filesystem_path)
|
||||
.unwrap_or_else(|_| unresolved_directory_identity(path))
|
||||
}
|
||||
|
||||
fn directory_identity(path: &str) -> String {
|
||||
directory_lookup(path).key
|
||||
}
|
||||
|
||||
fn pin_scope_key(path: &str, identity: &DirectoryIdentity) -> String {
|
||||
if identity.birth_verified {
|
||||
identity.key.clone()
|
||||
} else {
|
||||
format!("path:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the paths represented by this status only. The raw spelling remains
|
||||
/// the map key. Persistent home scope uses native identity only when birth time
|
||||
/// was verified; otherwise it conservatively follows the original home path.
|
||||
pub(super) fn session_directory_identities(
|
||||
codex_dir: &Path,
|
||||
sessions: &[SessionPreview],
|
||||
) -> (HashMap<String, String>, String) {
|
||||
let mut identities = HashMap::new();
|
||||
let home = codex_dir.display().to_string();
|
||||
let home_identity = directory_lookup(&home);
|
||||
let pin_scope = pin_scope_key(&home, &home_identity);
|
||||
identities.insert(home.clone(), home_identity.key);
|
||||
for path in sessions.iter().filter_map(|session| session.cwd.as_deref()) {
|
||||
identities
|
||||
.entry(path.to_string())
|
||||
.or_insert_with(|| directory_identity(path));
|
||||
}
|
||||
(identities, pin_scope)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestDirectory {
|
||||
path: PathBuf,
|
||||
_directory: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix("codex-x-directory-identity-");
|
||||
let directory = match std::env::var_os("CODEXX_TEST_CASE_DIRECTORY_ROOT") {
|
||||
Some(root) => builder.tempdir_in(root),
|
||||
None => builder.tempdir(),
|
||||
}
|
||||
.expect("create temporary directory identity fixture");
|
||||
Self {
|
||||
path: directory.path().to_path_buf(),
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(id: &str, cwd: Option<String>) -> SessionPreview {
|
||||
SessionPreview {
|
||||
id: id.to_string(),
|
||||
title: id.to_string(),
|
||||
cwd,
|
||||
model_provider: None,
|
||||
model: None,
|
||||
rollout_path: None,
|
||||
updated_at_ms: None,
|
||||
archived: false,
|
||||
has_user_event: false,
|
||||
is_subagent: false,
|
||||
needs_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_and_lexical_alias_have_the_same_stable_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
let direct = directory_identity(&folder.display().to_string());
|
||||
let alias = directory_identity(&folder.join(".").display().to_string());
|
||||
assert!(direct.starts_with("filesystem:"));
|
||||
assert_eq!(direct, alias);
|
||||
assert_eq!(direct, directory_identity(&folder.display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symbolic_directory_alias_resolves_to_the_same_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
let alias = fixture.path.join("WorkspaceAlias");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
std::os::unix::fs::symlink(&folder, &alias).unwrap();
|
||||
assert_eq!(
|
||||
directory_identity(&folder.display().to_string()),
|
||||
directory_identity(&alias.display().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_only_names_follow_the_actual_fixture_filesystem() {
|
||||
let fixture = TestDirectory::new();
|
||||
let upper = fixture.path.join("CaseDirectory");
|
||||
let lower = fixture.path.join("casedirectory");
|
||||
fs::create_dir(&upper).unwrap();
|
||||
let separate_directories = match fs::create_dir(&lower) {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false,
|
||||
Err(error) => panic!("create case-only directory fixture: {error}"),
|
||||
};
|
||||
let upper_key = directory_identity(&upper.display().to_string());
|
||||
let lower_key = directory_identity(&lower.display().to_string());
|
||||
assert!(upper_key.starts_with("filesystem:"));
|
||||
assert!(lower_key.starts_with("filesystem:"));
|
||||
if separate_directories {
|
||||
assert_ne!(
|
||||
upper_key, lower_key,
|
||||
"case-sensitive directories must remain distinct"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
upper_key, lower_key,
|
||||
"case aliases of the same directory must match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_never_borrow_identity_from_the_application_working_directory() {
|
||||
for raw in [".", "./", "src", "foo", "C:Project", " relative\\Name "] {
|
||||
assert_eq!(directory_identity(raw), format!("path:{raw}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_nondirectory_paths_keep_the_complete_original_spelling() {
|
||||
let fixture = TestDirectory::new();
|
||||
for suffix in ["Missing", "missing", "Missing\\Name ", " missing "] {
|
||||
let raw = fixture.path.join(suffix).display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
}
|
||||
let file = fixture.path.join("a-file");
|
||||
fs::write(&file, b"read-only identity lookup must not alter contents").unwrap();
|
||||
let raw = file.display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
assert_eq!(
|
||||
fs::read(&file).unwrap(),
|
||||
b"read-only identity lookup must not alter contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_directory_falls_back_without_normalizing_its_name() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("UnreadableDirectory");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0)).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let readable_by_process = File::open(&directory).is_ok();
|
||||
let identity = directory_identity(&raw);
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
if !readable_by_process {
|
||||
assert_eq!(identity, format!("path:{raw}"));
|
||||
} else {
|
||||
// A privileged test runner can bypass the permission restriction.
|
||||
assert!(identity.starts_with("filesystem:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_native_identifiers_are_rejected_and_full_windows_file_ids_are_used() {
|
||||
assert!(unix_directory_identity(1, 0, None).is_none());
|
||||
assert!(windows_directory_identity(1, &[0; 16], None).is_none());
|
||||
let mut first = [0; 16];
|
||||
first[0] = 7;
|
||||
let mut second = first;
|
||||
second[15] = 1;
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &first, None),
|
||||
windows_directory_identity(1, &second, None),
|
||||
"ReFS IDs that differ only beyond the old 64-bit index must not merge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_generation_includes_epoch_sign_full_seconds_and_nanoseconds() {
|
||||
use std::time::Duration;
|
||||
let created = UNIX_EPOCH + Duration::new(1234, 17);
|
||||
let original = unix_directory_identity(1, 9, Some(created)).unwrap();
|
||||
assert!(original.birth_verified);
|
||||
assert_eq!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(created)).unwrap()
|
||||
);
|
||||
for new_generation in [
|
||||
UNIX_EPOCH + Duration::new(1234, 18),
|
||||
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 17),
|
||||
UNIX_EPOCH - Duration::new(1234, 17),
|
||||
] {
|
||||
assert_ne!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(new_generation)).unwrap(),
|
||||
"reused inode numbers from another directory generation must get different keys"
|
||||
);
|
||||
}
|
||||
let file_id = [7; 16];
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &file_id, Some(created)),
|
||||
windows_directory_identity(1, &file_id, Some(created + Duration::from_nanos(1)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_pin_scope_requires_verified_birth_and_preserves_raw_fallback_paths() {
|
||||
let transient = unix_directory_identity(1, 9, None).unwrap();
|
||||
assert!(transient.key.starts_with("filesystem:"));
|
||||
assert!(!transient.birth_verified);
|
||||
for path in ["/Home/Codex", "/Home/codex", "/Home/Codex\\ "] {
|
||||
assert_eq!(pin_scope_key(path, &transient), format!("path:{path}"));
|
||||
}
|
||||
let verified = unix_directory_identity(1, 9, Some(UNIX_EPOCH)).unwrap();
|
||||
assert_eq!(pin_scope_key("/Home/Codex", &verified), verified.key);
|
||||
assert_eq!(pin_scope_key("/Home/codex", &verified), verified.key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_directory_contents_does_not_change_birth_identity_or_pin_scope() {
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("Workspace");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let before = directory_lookup(&raw);
|
||||
fs::write(directory.join("first.txt"), b"first").unwrap();
|
||||
fs::write(directory.join("first.txt"), b"updated").unwrap();
|
||||
fs::write(directory.join("second.txt"), b"second").unwrap();
|
||||
fs::remove_file(directory.join("first.txt")).unwrap();
|
||||
let after = directory_lookup(&raw);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"mtime/ctime changes must not replace the directory birth identity"
|
||||
);
|
||||
assert_eq!(pin_scope_key(&raw, &before), pin_scope_key(&raw, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_identities_include_only_its_home_and_loaded_workspaces_and_isolate_homes() {
|
||||
let fixture = TestDirectory::new();
|
||||
let first_home = fixture.path.join("CodexHomeA");
|
||||
let second_home = fixture.path.join("CodexHomeB");
|
||||
let workspace = fixture.path.join("Project");
|
||||
let unused = fixture.path.join("Unused");
|
||||
for directory in [&first_home, &second_home, &workspace, &unused] {
|
||||
fs::create_dir(directory).unwrap();
|
||||
}
|
||||
let config = first_home.join("config.toml");
|
||||
fs::write(&config, b"model_provider = \"openai\"\n").unwrap();
|
||||
let cwd = workspace.display().to_string();
|
||||
let sessions = vec![
|
||||
preview("first", Some(cwd.clone())),
|
||||
preview("second", Some(cwd.clone())),
|
||||
preview("unknown", None),
|
||||
];
|
||||
let (first, first_scope) = session_directory_identities(&first_home, &sessions);
|
||||
let (second, second_scope) = session_directory_identities(&second_home, &sessions);
|
||||
assert_eq!(first.len(), 2);
|
||||
assert!(first.contains_key(&first_home.display().to_string()));
|
||||
assert!(first.contains_key(&cwd));
|
||||
assert!(!first.contains_key(&unused.display().to_string()));
|
||||
assert_ne!(
|
||||
first[&first_home.display().to_string()],
|
||||
second[&second_home.display().to_string()]
|
||||
);
|
||||
assert_ne!(first_scope, second_scope);
|
||||
assert_eq!(first[&cwd], second[&cwd]);
|
||||
assert_eq!(fs::read(&config).unwrap(), b"model_provider = \"openai\"\n");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@ mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod directory_identity;
|
||||
mod export;
|
||||
mod global_state;
|
||||
mod rollout_stream;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod transaction;
|
||||
@@ -15,14 +18,16 @@ pub(crate) use backup::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
|
||||
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
|
||||
pub(crate) use export::{export_codex_sessions_inner, SessionExportResult};
|
||||
#[cfg(test)]
|
||||
pub(crate) use storage::{
|
||||
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
|
||||
scan_sqlite, sqlite_session_db_paths,
|
||||
};
|
||||
pub(crate) use storage::{
|
||||
session_project_title, session_titles_by_id, source_value_is_subagent, sqlite_candidate_paths,
|
||||
usage_thread_identities, UsageThreadIdentity,
|
||||
session_project_title, session_titles_by_id, source_kind_is_internal, sqlite_candidate_paths,
|
||||
sqlite_candidate_paths_with_timeout, thread_source_is_internal, usage_thread_identities,
|
||||
UsageThreadIdentity,
|
||||
};
|
||||
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
|
||||
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
|
||||
use super::catalog::{scan_catalog_sync, CatalogSyncScan};
|
||||
use super::catalog::{scan_catalog_internal_thread_ids, scan_catalog_sync, CatalogSyncScan};
|
||||
use super::directory_identity::session_directory_identities;
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
list_session_previews_with_paths, scan_provider_rollouts, scan_rollouts_for_thread_ids,
|
||||
scan_sqlite_with_paths, SqliteDiscovery,
|
||||
list_session_previews_with_paths, rollout_internal_thread_ids, scan_provider_rollouts,
|
||||
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
|
||||
};
|
||||
use super::transaction::{
|
||||
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
|
||||
@@ -85,16 +86,22 @@ fn scan_provider_sync_data(
|
||||
target_provider: &str,
|
||||
discovery: &SqliteDiscovery,
|
||||
) -> Result<ProviderSyncScan> {
|
||||
let active_sqlite = scan_sqlite_with_paths(
|
||||
let active_unclassified = scan_sqlite_with_paths(
|
||||
&discovery.active_paths,
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let mut sqlite = scan_sqlite_with_paths(
|
||||
&discovery.thread_paths,
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let (mut internal_thread_ids, _classification_failures) =
|
||||
rollout_internal_thread_ids(codex_dir, &active_unclassified.rollout_paths_by_thread_id);
|
||||
internal_thread_ids.extend(scan_catalog_internal_thread_ids(&discovery.related_paths)?);
|
||||
let classification = RolloutScan {
|
||||
internal_thread_ids,
|
||||
..RolloutScan::default()
|
||||
};
|
||||
let active_sqlite =
|
||||
scan_sqlite_with_paths(&discovery.active_paths, &classification, target_provider)?;
|
||||
let mut sqlite =
|
||||
scan_sqlite_with_paths(&discovery.thread_paths, &classification, target_provider)?;
|
||||
let mut syncable_thread_ids = sqlite.syncable_thread_ids.clone();
|
||||
let mut archived_thread_ids = sqlite.archived_thread_ids.clone();
|
||||
let mut subagent_thread_ids = sqlite.subagent_thread_ids.clone();
|
||||
@@ -111,6 +118,7 @@ fn scan_provider_sync_data(
|
||||
}
|
||||
let mut excluded_thread_ids = archived_thread_ids.clone();
|
||||
excluded_thread_ids.extend(subagent_thread_ids.iter().cloned());
|
||||
excluded_thread_ids.extend(classification.internal_thread_ids.iter().cloned());
|
||||
// Without an active authority, conflicting legacy classifications stay conservatively excluded.
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
mismatched_thread_ids.retain(|id| syncable_thread_ids.contains(id));
|
||||
@@ -120,15 +128,41 @@ fn scan_provider_sync_data(
|
||||
sqlite.mismatched_thread_ids = mismatched_thread_ids;
|
||||
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
|
||||
|
||||
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
// Only diagnostics from this authority scan are needed below. Drop its
|
||||
// planned file copies before scanning the complete provider history.
|
||||
let (indexed_rollout_failures, indexed_blocked_failures) = {
|
||||
let rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
};
|
||||
(rollouts.scan_failures, rollouts.blocked_failures)
|
||||
};
|
||||
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
|
||||
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &excluded_thread_ids)?;
|
||||
let mut rollouts = scan_provider_rollouts(
|
||||
codex_dir,
|
||||
target_provider,
|
||||
&excluded_thread_ids,
|
||||
&active_unclassified.rollout_paths_by_thread_id,
|
||||
)?;
|
||||
// Provider synchronization changes provider routing only; cwd remains independently managed.
|
||||
rollouts.cwd_by_thread_id.clear();
|
||||
// Rollout metadata can reveal an internal thread even when the SQLite row
|
||||
// has no source column (or an older index still calls it a user thread).
|
||||
excluded_thread_ids.extend(rollouts.internal_thread_ids.iter().cloned());
|
||||
rollouts
|
||||
.internal_thread_ids
|
||||
.extend(classification.internal_thread_ids.iter().cloned());
|
||||
sqlite
|
||||
.subagent_thread_ids
|
||||
.extend(rollouts.internal_thread_ids.iter().cloned());
|
||||
sqlite
|
||||
.syncable_thread_ids
|
||||
.retain(|id| !excluded_thread_ids.contains(id));
|
||||
sqlite
|
||||
.mismatched_thread_ids
|
||||
.retain(|id| !excluded_thread_ids.contains(id));
|
||||
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
|
||||
syncable_thread_ids.extend(rollouts.thread_ids.iter().cloned());
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
let catalog = scan_catalog_sync(
|
||||
@@ -141,27 +175,24 @@ fn scan_provider_sync_data(
|
||||
let mut scan_failures = discovery.active_scan_failures.clone();
|
||||
scan_failures.extend(active_sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(sqlite.scan_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_rollouts.scan_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_rollout_failures.iter().cloned());
|
||||
scan_failures.extend(indexed_blocked_failures);
|
||||
// A size/buffer limit means the rollout was not completely inspected.
|
||||
// This is blocking even for an orphan, rather than a success with a warning.
|
||||
scan_failures.extend(rollouts.blocked_failures.iter().cloned());
|
||||
// A failed eligible rollout read/parse is an incomplete check, including
|
||||
// unindexed files. It must not become an apparently successful sync.
|
||||
scan_failures.extend(rollouts.scan_failures.iter().cloned());
|
||||
for path in &discovery.unreadable_paths {
|
||||
scan_failures.push(format!("无法读取会话数据库: {}", path.display()));
|
||||
}
|
||||
|
||||
let indexed_failures = indexed_rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
let mut warnings = rollouts.warnings.clone();
|
||||
// The full provider/index scans below own warnings and blocking failures;
|
||||
// repeating the lightweight identity scan's messages would duplicate them.
|
||||
warnings.extend(active_sqlite.warnings.iter().cloned());
|
||||
warnings.extend(sqlite.warnings.iter().cloned());
|
||||
warnings.extend(legacy_index_warnings);
|
||||
warnings.extend(
|
||||
rollouts
|
||||
.scan_failures
|
||||
.iter()
|
||||
.filter(|failure| !indexed_failures.contains(*failure))
|
||||
.map(|failure| format!("已跳过未进入会话索引的异常文件:{failure}")),
|
||||
);
|
||||
let mut seen = HashSet::new();
|
||||
scan_failures.retain(|failure| seen.insert(failure.clone()));
|
||||
seen.clear();
|
||||
@@ -215,8 +246,12 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
Err(error) => {
|
||||
let mut failures = scan.scan_failures.clone();
|
||||
failures.push(format!("无法读取当前活动会话列表: {error}"));
|
||||
let (directory_identities, pin_scope_key) =
|
||||
session_directory_identities(codex_dir, &[]);
|
||||
return Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
directory_identities,
|
||||
pin_scope_key: Some(pin_scope_key),
|
||||
target_provider: target,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
@@ -247,15 +282,21 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
let mut mismatched_ids = sqlite_mismatch_ids.clone();
|
||||
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
|
||||
for session in &mut sessions {
|
||||
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
|
||||
session.needs_sync = true;
|
||||
}
|
||||
// The same eligibility set drives both UI status and every mutation path.
|
||||
session.needs_sync = !session.archived
|
||||
&& !session.is_subagent
|
||||
&& scan.syncable_thread_ids.contains(&session.id)
|
||||
&& mismatched_ids.contains(&session.id);
|
||||
}
|
||||
let needs_sync = !scan.rollouts.changes.is_empty()
|
||||
|| !scan.rollouts.mismatched_thread_ids.is_empty()
|
||||
|| scan.sqlite.mismatched_threads > 0
|
||||
|| scan.catalog.total_updates() > 0;
|
||||
let (directory_identities, pin_scope_key) = session_directory_identities(codex_dir, &sessions);
|
||||
Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
directory_identities,
|
||||
pin_scope_key: Some(pin_scope_key),
|
||||
target_provider: target,
|
||||
rollout_files: scan.rollouts.rollout_files,
|
||||
session_meta_count: scan.rollouts.session_meta_count,
|
||||
@@ -359,6 +400,7 @@ where
|
||||
backup_dir: String::new(),
|
||||
});
|
||||
}
|
||||
drop(preflight_scan);
|
||||
|
||||
hook(MutationPoint::BeforeSqliteLock)?;
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
|
||||
@@ -379,6 +421,14 @@ where
|
||||
{
|
||||
rollback_open_transactions(&mut pending_sqlite);
|
||||
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
|
||||
if !status.scan_complete {
|
||||
return Err(scan_failure_error(&status.scan_failures));
|
||||
}
|
||||
if status.needs_sync {
|
||||
return Err(CodexxError::Config(
|
||||
"会话状态在检查后发生变化,未完成同步;请重新检查。".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: 0,
|
||||
@@ -426,25 +476,32 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
|
||||
let mut status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)
|
||||
.and_then(|status| {
|
||||
if !status.scan_complete {
|
||||
return Err(scan_failure_error(&status.scan_failures));
|
||||
}
|
||||
if status.needs_sync {
|
||||
return Err(CodexxError::Config(
|
||||
"会话日志与索引仍不一致,未完成同步;请重新检查。".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(status)
|
||||
})
|
||||
.map_err(|error| {
|
||||
CodexxError::Config(format!(
|
||||
"同步已完成,但刷新会话列表失败,请重新进入页面:{error}"
|
||||
))
|
||||
let recovery_errors = rollback_mutation(&journal, &mut pending_sqlite);
|
||||
mutation_error(
|
||||
CodexxError::Config(format!("同步后复查未通过,本轮同步未完成:{error}")),
|
||||
recovery_errors,
|
||||
)
|
||||
})?;
|
||||
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
|
||||
status.backup_dir = Some(backup.dir.display().to_string());
|
||||
if prune_warning.is_some() {
|
||||
status
|
||||
.warnings
|
||||
.push("同步已完成,但旧备份暂未清理。".to_string());
|
||||
}
|
||||
if !mutation.skipped_rollouts.is_empty() {
|
||||
status.warnings.push(format!(
|
||||
"有 {} 个会话正在使用,已跳过;退出 Codex 后再同步即可。",
|
||||
mutation.skipped_rollouts.len()
|
||||
));
|
||||
}
|
||||
Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: mutation.applied_rollouts,
|
||||
@@ -457,9 +514,12 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{BufRead, Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const SHARED_SESSION_PROVIDER: &str = "custom";
|
||||
const LEGACY_LIMIT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -644,6 +704,383 @@ mod tests {
|
||||
.expect("write rollout");
|
||||
}
|
||||
|
||||
fn pad_rollout_to_size(path: &Path, size: u64) {
|
||||
let mut file = fs::OpenOptions::new().append(true).open(path).unwrap();
|
||||
let mut remaining = size.checked_sub(file.metadata().unwrap().len()).unwrap();
|
||||
let padding = [b' '; 64 * 1024];
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(padding.len() as u64) as usize;
|
||||
file.write_all(&padding[..count]).unwrap();
|
||||
remaining -= count as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn rollout_digest(path: &Path) -> Vec<u8> {
|
||||
rollout_digest_from_offset(path, 0)
|
||||
}
|
||||
|
||||
fn rollout_digest_from_offset(path: &Path, offset: u64) -> Vec<u8> {
|
||||
let mut file = fs::File::open(path).unwrap();
|
||||
file.seek(SeekFrom::Start(offset)).unwrap();
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer).unwrap();
|
||||
if count == 0 {
|
||||
return digest.finalize().to_vec();
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_chat_record(path: &Path) {
|
||||
fs::OpenOptions::new().append(true).open(path).unwrap()
|
||||
.write_all(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"keep this conversation\"}}\n")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn read_first_rollout_record(path: &Path) -> String {
|
||||
let mut first_record = String::new();
|
||||
std::io::BufReader::new(fs::File::open(path).unwrap())
|
||||
.read_line(&mut first_record)
|
||||
.unwrap();
|
||||
first_record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_rollouts_below_at_and_above_legacy_limit_sync_logs_databases_and_catalogs() {
|
||||
for (name, size) in [
|
||||
("normal", 512),
|
||||
("below-old-limit", LEGACY_LIMIT - 1),
|
||||
("exact-old-limit", LEGACY_LIMIT),
|
||||
("above-old-limit", LEGACY_LIMIT + 1),
|
||||
] {
|
||||
let dir = temp_codex_dir(name);
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000701";
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let old_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
append_chat_record(&rollout);
|
||||
pad_rollout_to_size(&rollout, size);
|
||||
let conversation_before = rollout_digest_from_offset(&rollout, old_record_len);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(status.scan_complete, "{name}: {:?}", status.scan_failures);
|
||||
assert!(status.needs_sync, "{name}");
|
||||
let result =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1, "{name}");
|
||||
assert_eq!(thread_provider(&database, id), "custom", "{name}");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom", "{name}");
|
||||
let first_record = read_first_rollout_record(&rollout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollout).unwrap().len(), size, "{name}");
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollout, first_record.len() as u64),
|
||||
conversation_before,
|
||||
"conversation bytes must be preserved: {name}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_ninety_seven_mib_single_chat_line_syncs_without_losing_conversation_bytes() {
|
||||
let dir = temp_codex_dir("streamed-97-mib-chat-record");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000709";
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
let total_size = 97 * 1024 * 1024u64;
|
||||
let prefix =
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"";
|
||||
let suffix = b"\"}}\n";
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&rollout).unwrap();
|
||||
file.write_all(prefix).unwrap();
|
||||
let mut remaining =
|
||||
total_size - first_record_len - prefix.len() as u64 - suffix.len() as u64;
|
||||
let body_chunk = [b'x'; 64 * 1024];
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(body_chunk.len() as u64) as usize;
|
||||
file.write_all(&body_chunk[..count]).unwrap();
|
||||
remaining -= count as u64;
|
||||
}
|
||||
file.write_all(suffix).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
let conversation_before = rollout_digest_from_offset(&rollout, first_record_len);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
|
||||
.expect("a large valid single JSONL record must synchronize successfully");
|
||||
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom");
|
||||
let first_record = read_first_rollout_record(&rollout);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollout).unwrap().len(), total_size);
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollout, first_record.len() as u64),
|
||||
conversation_before,
|
||||
"the large chat record must retain every original byte"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_multiple_rollouts_exceeding_legacy_snapshot_budget_sync_together() {
|
||||
let dir = temp_codex_dir("streamed-multi-large-rollout-budget");
|
||||
write_config(&dir, "custom");
|
||||
let ids = [
|
||||
"019f6000-0000-7000-8000-000000000711",
|
||||
"019f6000-0000-7000-8000-000000000712",
|
||||
"019f6000-0000-7000-8000-000000000713",
|
||||
];
|
||||
let file_size = 45 * 1024 * 1024u64;
|
||||
assert!(file_size * ids.len() as u64 > 128 * 1024 * 1024);
|
||||
let mut rollouts = Vec::new();
|
||||
let mut conversation_digests = Vec::new();
|
||||
for id in ids {
|
||||
let rollout = write_rollout(&dir, id, "openai");
|
||||
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
|
||||
append_chat_record(&rollout);
|
||||
pad_rollout_to_size(&rollout, file_size);
|
||||
conversation_digests.push(rollout_digest_from_offset(&rollout, first_record_len));
|
||||
rollouts.push(rollout);
|
||||
}
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, ids[0], "openai", &rollouts[0]);
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
for index in 1..ids.len() {
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'large history', ?2)",
|
||||
(ids[index], rollouts[index].display().to_string()),
|
||||
).unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &ids.map(|id| (id, "openai")));
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
|
||||
.expect("the combined size of valid rollouts must not block synchronization");
|
||||
|
||||
assert!(
|
||||
result.status.scan_complete && !result.status.needs_sync,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, ids.len());
|
||||
for index in 0..ids.len() {
|
||||
assert_eq!(thread_provider(&database, ids[index]), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, ids[index]), "custom");
|
||||
let first_record = read_first_rollout_record(&rollouts[index]);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
|
||||
["model_provider"],
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fs::metadata(&rollouts[index]).unwrap().len(), file_size);
|
||||
assert_eq!(
|
||||
rollout_digest_from_offset(&rollouts[index], first_record.len() as u64),
|
||||
conversation_digests[index],
|
||||
"conversation bytes must remain unchanged for {}",
|
||||
ids[index]
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_invalid_jsonl_rollout_reports_parse_failure_and_keeps_indexes_unchanged() {
|
||||
for (name, file_provider, database_provider) in [
|
||||
("file-and-index-old", "openai", "openai"),
|
||||
("index-already-target", "openai", "custom"),
|
||||
("first-record-matches", "custom", "custom"),
|
||||
] {
|
||||
let dir = temp_codex_dir(name);
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000702";
|
||||
let rollout = write_rollout(&dir, id, file_provider);
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&rollout)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
let before = rollout_digest(&rollout);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, database_provider, &rollout);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, database_provider)]);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete, "{name}");
|
||||
assert!(
|
||||
status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("JSONL")),
|
||||
"{name}: {:?}",
|
||||
status.scan_failures
|
||||
);
|
||||
let error =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert!(error.to_string().contains("JSONL"), "{name}: {error}");
|
||||
assert_eq!(thread_provider(&database, id), database_provider, "{name}");
|
||||
assert_eq!(catalog_provider(&catalog, id), database_provider, "{name}");
|
||||
assert_eq!(rollout_digest(&rollout), before, "{name}");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_invalid_orphan_blocks_sync_without_changing_normal_logs_or_indexes() {
|
||||
let dir = temp_codex_dir("large-invalid-orphan-blocked");
|
||||
write_config(&dir, "custom");
|
||||
let ordinary_id = "019f6000-0000-7000-8000-000000000703";
|
||||
let ordinary = write_rollout(&dir, ordinary_id, "openai");
|
||||
let before = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, ordinary_id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(ordinary_id, "openai")]);
|
||||
let orphan_id = "019f6000-0000-7000-8000-000000000704";
|
||||
let invalid_orphan = write_rollout(&dir, orphan_id, "openai");
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&invalid_orphan)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
let orphan_before = rollout_digest(&invalid_orphan);
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete && status.needs_sync);
|
||||
let error =
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert!(error.to_string().contains("JSONL"), "{error}");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), before);
|
||||
assert_eq!(rollout_digest(&invalid_orphan), orphan_before);
|
||||
assert_eq!(thread_provider(&database, ordinary_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, ordinary_id), "openai");
|
||||
assert!(catalog_provider_and_visibility(&catalog, orphan_id).is_none());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_orphan_read_or_parse_cannot_be_reported_as_successful_sync() {
|
||||
for suffix in [b"\xff".as_slice(), b"not-json\n".as_slice()] {
|
||||
let dir = temp_codex_dir("failed-orphan-check-blocks-sync");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000705";
|
||||
let ordinary = write_rollout(&dir, id, "openai");
|
||||
let original = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
let orphan = write_rollout(&dir, "019f6000-0000-7000-8000-000000000706", "openai");
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&orphan)
|
||||
.unwrap()
|
||||
.write_all(suffix)
|
||||
.unwrap();
|
||||
let orphan_original = fs::read(&orphan).unwrap();
|
||||
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete && !status.scan_failures.is_empty());
|
||||
assert!(sync_sessions_provider_inner(Some(dir.display().to_string()), None).is_err());
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, id), "openai");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), original);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), orphan_original);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_final_recheck_rolls_back_committed_logs_and_indexes() {
|
||||
for scenario in ["invalid", "large-invalid", "new-mismatch"] {
|
||||
let dir = temp_codex_dir("failed-final-recheck");
|
||||
write_config(&dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000707";
|
||||
let ordinary = write_rollout(&dir, id, "openai");
|
||||
let original = fs::read(&ordinary).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
let late = dir.join("sessions/rollout-test-019f6000-0000-7000-8000-000000000708.jsonl");
|
||||
let mut late_digest = None;
|
||||
|
||||
let error =
|
||||
sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
|
||||
if matches!(point, MutationPoint::AfterSqliteCommit(_)) && !late.exists() {
|
||||
if scenario == "invalid" {
|
||||
fs::write(&late, b"\xff").unwrap();
|
||||
} else {
|
||||
write_rollout_at(
|
||||
&late,
|
||||
"019f6000-0000-7000-8000-000000000708",
|
||||
"openai",
|
||||
);
|
||||
if scenario == "large-invalid" {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&late)
|
||||
.unwrap()
|
||||
.set_len(LEGACY_LIMIT + 1)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
late_digest = Some(rollout_digest(&late));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a failed final check must not return a successful sync");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("同步后复查未通过"),
|
||||
"{scenario}: {error}"
|
||||
);
|
||||
assert_eq!(thread_provider(&database, id), "openai", "{scenario}");
|
||||
assert_eq!(catalog_provider(&catalog, id), "openai", "{scenario}");
|
||||
assert_eq!(fs::read(&ordinary).unwrap(), original, "{scenario}");
|
||||
assert_eq!(
|
||||
rollout_digest(&late),
|
||||
late_digest.unwrap(),
|
||||
"preserve external file: {scenario}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subagent_rollout(
|
||||
codex_dir: &Path,
|
||||
id: &str,
|
||||
@@ -672,6 +1109,176 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
fn rows_except_parent(
|
||||
path: &Path,
|
||||
table: &str,
|
||||
id_column: &str,
|
||||
parent: &str,
|
||||
) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
let conn = Connection::open(path).expect("open snapshot database");
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT * FROM {table} WHERE {id_column} <> ?1 ORDER BY {id_column}"
|
||||
))
|
||||
.expect("prepare row snapshot");
|
||||
let column_count = stmt.column_count();
|
||||
stmt.query_map([parent], |row| {
|
||||
(0..column_count).map(|index| row.get(index)).collect()
|
||||
})
|
||||
.expect("read row snapshots")
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.expect("collect row snapshots")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_rollout_evidence_prevents_catalog_reactivation_and_insertion() {
|
||||
let codex_dir = temp_codex_dir("internal-catalog-regression");
|
||||
write_config(&codex_dir, "custom");
|
||||
let parent = "019f6000-0000-7000-8000-000000000900";
|
||||
let hidden = "019f6000-0000-7000-8000-000000000901";
|
||||
let missing = "019f6000-0000-7000-8000-000000000902";
|
||||
let catalog_only = "019f6000-0000-7000-8000-000000000903";
|
||||
let source_only = "019f6000-0000-7000-8000-000000000904";
|
||||
let parent_rollout = write_rollout(&codex_dir, parent, "openai");
|
||||
let state = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database_with_rollout(&state, parent, "openai", &parent_rollout);
|
||||
let conn = Connection::open(&state).expect("open regression index");
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE threads ADD COLUMN source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN thread_source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN parent_thread_id TEXT;
|
||||
ALTER TABLE threads ADD COLUMN cwd TEXT;",
|
||||
)
|
||||
.expect("extend source metadata");
|
||||
conn.execute("UPDATE threads SET title = ?1, source = 'cli', thread_source = 'user', cwd = '/tmp/project' WHERE id = ?2",
|
||||
("Review this error: The following is the Codex agent history whose request action you are assessing", parent))
|
||||
.expect("set genuine parent title");
|
||||
let mut internal_files = Vec::new();
|
||||
for (id, rollout_source, sqlite_source) in [
|
||||
(hidden, serde_json::json!({"internal": "guardian"}), "cli"),
|
||||
(
|
||||
missing,
|
||||
serde_json::json!({"subagent": {"review": null}}),
|
||||
"cli",
|
||||
),
|
||||
(catalog_only, serde_json::json!("cli"), "cli"),
|
||||
(source_only, serde_json::json!("cli"), "subagent_review"),
|
||||
] {
|
||||
let path = write_rollout(&codex_dir, id, "openai");
|
||||
let record = serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id": id, "model_provider":"openai", "source":rollout_source, "cwd":"/tmp/private"
|
||||
}});
|
||||
fs::write(&path, format!("{record}\n")).expect("write source metadata");
|
||||
conn.execute("INSERT INTO threads (id, model_provider, title, rollout_path, source, thread_source, cwd)
|
||||
VALUES (?1, 'openai', 'codex-auto-review', ?2, ?3, NULL, '/tmp/private')",
|
||||
(id, path.display().to_string(), sqlite_source)).expect("insert internal source");
|
||||
internal_files.push((
|
||||
path.clone(),
|
||||
fs::read(&path).expect("snapshot internal rollout"),
|
||||
));
|
||||
}
|
||||
drop(conn);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[
|
||||
(parent, "openai"),
|
||||
(hidden, "openai"),
|
||||
(catalog_only, "openai"),
|
||||
],
|
||||
);
|
||||
let conn = Connection::open(&catalog).expect("open regression catalog");
|
||||
conn.execute(
|
||||
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
|
||||
[hidden],
|
||||
)
|
||||
.expect("keep review hidden");
|
||||
conn.execute("UPDATE local_thread_catalog SET source_kind = 'internal', thread_source = 'guardian_review', missing_candidate = 1 WHERE thread_id = ?1", [catalog_only])
|
||||
.expect("mark catalog-only internal source");
|
||||
drop(conn);
|
||||
let before_threads = rows_except_parent(&state, "threads", "id", parent);
|
||||
let before_catalog =
|
||||
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent);
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan mixed sources");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.mismatched_sessions, 1, "{status:?}");
|
||||
assert!(
|
||||
status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == parent)
|
||||
.expect("parent preview")
|
||||
.needs_sync
|
||||
);
|
||||
for session in status
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|session| session.id != parent)
|
||||
{
|
||||
assert!(session.is_subagent, "internal preview {}", session.id);
|
||||
assert!(
|
||||
!session.needs_sync,
|
||||
"internal must not be pending {}",
|
||||
session.id
|
||||
);
|
||||
}
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync only parent");
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&state, parent), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, parent), "custom");
|
||||
assert_eq!(
|
||||
rows_except_parent(&state, "threads", "id", parent),
|
||||
before_threads
|
||||
);
|
||||
assert_eq!(
|
||||
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent),
|
||||
before_catalog
|
||||
);
|
||||
assert!(catalog_provider_and_visibility(&catalog, missing).is_none());
|
||||
assert!(catalog_provider_and_visibility(&catalog, source_only).is_none());
|
||||
for (path, original) in internal_files {
|
||||
assert_eq!(
|
||||
fs::read(path).expect("read preserved internal rollout"),
|
||||
original
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(codex_dir).expect("remove regression fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_user_rollout_is_not_hidden_by_an_internal_duplicate() {
|
||||
let codex_dir = temp_codex_dir("user-internal-duplicate");
|
||||
write_config(&codex_dir, "custom");
|
||||
let id = "019f6000-0000-7000-8000-000000000905";
|
||||
let active = codex_dir.join("sessions/rollout-active-name.jsonl");
|
||||
write_rollout_at(&active, id, "openai");
|
||||
let stale = write_subagent_rollout(&codex_dir, id, "old-parent", "openai");
|
||||
let stale_bytes = fs::read(&stale).expect("snapshot stale internal duplicate");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &active);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan authoritative user");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.subagent_threads, 0);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
assert!(!status.sessions[0].is_subagent);
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync authoritative user");
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
assert_eq!(catalog_provider(&catalog, id), "custom");
|
||||
assert_eq!(
|
||||
fs::read(stale).expect("read untouched internal duplicate"),
|
||||
stale_bytes
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove duplicate fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_provider_is_used_as_the_sync_target() {
|
||||
let codex_dir = temp_codex_dir("live-provider-target");
|
||||
@@ -773,7 +1380,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_standard_orphan_rollout_does_not_block_active_sessions() {
|
||||
fn malformed_standard_orphan_keeps_check_incomplete_without_mutation() {
|
||||
let codex_dir = temp_codex_dir("malformed-standard-orphan");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000502";
|
||||
@@ -783,26 +1390,30 @@ mod tests {
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl")),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid orphan rollout");
|
||||
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
let active_before = fs::read(&active).unwrap();
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let orphan = codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl"));
|
||||
fs::write(&orphan, b"\xff").expect("write invalid orphan rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan active sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.scan_failures.is_empty());
|
||||
assert_eq!(status.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
|
||||
assert_eq!(fs::read(&active).unwrap(), active_before);
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_unreferenced_nonstandard_rollout_does_not_block_active_sessions() {
|
||||
fn malformed_unreferenced_rollout_keeps_check_incomplete_without_mutation() {
|
||||
let codex_dir = temp_codex_dir("malformed-unreferenced-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000504";
|
||||
@@ -811,19 +1422,23 @@ mod tests {
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join("sessions/rollout-imported-orphan.jsonl"),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid unreferenced rollout");
|
||||
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
let active_before = fs::read(&active).unwrap();
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
let database_before = fs::read(&database).unwrap();
|
||||
let orphan = codex_dir.join("sessions/rollout-imported-orphan.jsonl");
|
||||
fs::write(&orphan, b"\xff").expect("write invalid unreferenced rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan referenced sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.scan_failures.is_empty());
|
||||
assert_eq!(status.rollout_files, 2);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert_eq!(status.warnings.len(), 1);
|
||||
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
|
||||
assert_eq!(fs::read(&active).unwrap(), active_before);
|
||||
assert_eq!(fs::read(&database).unwrap(), database_before);
|
||||
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -860,6 +1475,111 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverted_rollout_sync_uses_stable_thread_id_and_keeps_internal_threads_hidden() {
|
||||
let codex_dir = temp_codex_dir("reverted-rollout-sync");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let parent = "019f6000-0000-7000-8000-000000000920";
|
||||
let parent_rollout_id = "019f6000-0000-7000-8000-000000000921";
|
||||
let internal = "019f6000-0000-7000-8000-000000000922";
|
||||
let internal_rollout_id = "019f6000-0000-7000-8000-000000000923";
|
||||
let parent_path = codex_dir.join(format!(
|
||||
"sessions/rollout-2026-09-17T13-10-16-{parent}_{parent_rollout_id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&parent_path, parent, "openai");
|
||||
let internal_path = codex_dir.join(format!(
|
||||
"sessions/rollout-2026-09-17T13-10-17-{internal}_{internal_rollout_id}.jsonl"
|
||||
));
|
||||
let internal_metadata = serde_json::json!({"type":"session_meta","payload":{
|
||||
"id":internal, "model_provider":"openai", "source":{"internal":"guardian"}
|
||||
}});
|
||||
fs::write(&internal_path, format!("{internal_metadata}\n"))
|
||||
.expect("write reverted internal rollout");
|
||||
let internal_bytes = fs::read(&internal_path).expect("snapshot internal rollout");
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, parent, "openai", &parent_path);
|
||||
Connection::open(&database).expect("open reverted thread index").execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'internal review', ?2)",
|
||||
(internal, internal_path.display().to_string()),
|
||||
).expect("insert internal thread without database source markers");
|
||||
let before_internal = rows_except_parent(&database, "threads", "id", parent);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("check reverted rollouts");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status
|
||||
.warnings
|
||||
.iter()
|
||||
.all(|warning| !warning.contains("线程 ID 不一致")));
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
assert_eq!(status.subagent_threads, 1);
|
||||
for preview in &status.sessions {
|
||||
assert_eq!(preview.is_subagent, preview.id == internal);
|
||||
assert_eq!(preview.needs_sync, preview.id == parent);
|
||||
}
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize reverted user rollout through transaction guards");
|
||||
assert!(result.status.scan_complete);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, parent), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(catalog_provider(&catalog, parent), SHARED_SESSION_PROVIDER);
|
||||
for excluded in [parent_rollout_id, internal, internal_rollout_id] {
|
||||
assert!(catalog_provider_and_visibility(&catalog, excluded).is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
rows_except_parent(&database, "threads", "id", parent),
|
||||
before_internal
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&internal_path).expect("read unchanged internal rollout"),
|
||||
internal_bytes
|
||||
);
|
||||
let record: serde_json::Value = serde_json::from_str(
|
||||
fs::read_to_string(&parent_path)
|
||||
.expect("read synchronized parent")
|
||||
.trim(),
|
||||
)
|
||||
.expect("parse synchronized parent metadata");
|
||||
assert_eq!(record["payload"]["id"], parent);
|
||||
assert_eq!(record["payload"]["model_provider"], SHARED_SESSION_PROVIDER);
|
||||
fs::remove_dir_all(codex_dir).expect("remove reverted rollout fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverted_rollout_cannot_be_indexed_by_its_distinct_rollout_id() {
|
||||
let codex_dir = temp_codex_dir("reverted-rollout-wrong-index-id");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let thread_id = "019f6000-0000-7000-8000-000000000924";
|
||||
let rollout_id = "019f6000-0000-7000-8000-000000000925";
|
||||
let path = codex_dir.join(format!(
|
||||
"sessions/rollout-2026-09-17T13-10-16-{thread_id}_{rollout_id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&path, thread_id, "openai");
|
||||
let original = fs::read(&path).expect("snapshot original rollout");
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, rollout_id, "openai", &path);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("check mismatched reverted rollout reference");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("线程 ID 不一致")));
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("rollout ID must not substitute for its stable thread ID");
|
||||
assert_eq!(thread_provider(&database, rollout_id), "openai");
|
||||
assert_eq!(fs::read(&path).expect("read protected rollout"), original);
|
||||
assert!(catalog_provider_and_visibility(&catalog, thread_id).is_none());
|
||||
assert!(catalog_provider_and_visibility(&catalog, rollout_id).is_none());
|
||||
fs::remove_dir_all(codex_dir).expect("remove invalid reverted reference fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_referenced_rollout_with_a_different_session_id_blocks_sync() {
|
||||
let codex_dir = temp_codex_dir("referenced-rollout-id-mismatch");
|
||||
@@ -1125,7 +1845,7 @@ mod tests {
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert!(result.updated_threads > 0);
|
||||
assert_eq!(thread_provider(&active, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(thread_provider(&legacy, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(thread_provider(&legacy, id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, id), SHARED_SESSION_PROVIDER);
|
||||
assert!(fs::read_to_string(rollout)
|
||||
.expect("read synchronized active user rollout")
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::catalog::{
|
||||
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
|
||||
restore_catalog_updates, CatalogRepairThread,
|
||||
};
|
||||
use super::storage::{apply_session_changes, restore_session_changes};
|
||||
use super::storage::{
|
||||
apply_session_changes_with_hook, hash_rollout_file, restore_session_changes,
|
||||
rollout_path_has_syncable_identity, rollout_text_is_internal, sqlite_subagent_thread_ids,
|
||||
};
|
||||
use super::types::{RolloutScan, SessionFileChange};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
@@ -34,6 +37,7 @@ impl SqliteUpdateCounts {
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct MutationJournal {
|
||||
applied_rollouts: Vec<SessionFileChange>,
|
||||
expected_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
|
||||
}
|
||||
|
||||
@@ -182,6 +186,11 @@ fn apply_sqlite_updates(
|
||||
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
|
||||
sorted_thread_ids.sort();
|
||||
for update in pending.iter_mut() {
|
||||
let internal_ids = if update.thread_columns.contains("id") {
|
||||
sqlite_subagent_thread_ids(&update.conn, &update.thread_columns)?
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
let archived_filter = if update.thread_columns.contains("archived") {
|
||||
@@ -203,6 +212,9 @@ fn apply_sqlite_updates(
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for thread_id in &sorted_thread_ids {
|
||||
if internal_ids.contains(*thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
.conn
|
||||
.execute(&snapshot_sql, (target_provider, thread_id))
|
||||
@@ -234,7 +246,7 @@ fn apply_sqlite_updates(
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
if !syncable_thread_ids.contains(thread_id) {
|
||||
if !syncable_thread_ids.contains(thread_id) || internal_ids.contains(thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
@@ -247,12 +259,16 @@ fn apply_sqlite_updates(
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let local_syncable_ids = syncable_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let catalog_counts = apply_catalog_updates(
|
||||
&update.conn,
|
||||
&update.catalog_columns,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
syncable_thread_ids,
|
||||
&local_syncable_ids,
|
||||
)?;
|
||||
update.counts.provider_rows += catalog_counts.provider_rows;
|
||||
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
|
||||
@@ -270,6 +286,7 @@ where
|
||||
{
|
||||
let mut updated = SqliteUpdateCounts::default();
|
||||
for index in 0..pending.len() {
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let before_commit = sqlite_data_version(&pending[index].observer)?;
|
||||
journal.sqlite_restore_attempts.push(SqliteRestoreAttempt {
|
||||
path: pending[index].path.clone(),
|
||||
@@ -296,6 +313,9 @@ where
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// The final commit hook can publish an external rollout update as well.
|
||||
// Returning success requires the complete candidate set to remain verified.
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -402,15 +422,86 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
BeforeSqliteLock,
|
||||
BeforeRolloutMutation,
|
||||
BeforeEachRolloutMutation(usize),
|
||||
AfterRolloutMutation,
|
||||
AfterSqliteCommit(usize),
|
||||
}
|
||||
|
||||
pub(super) struct MutationResult {
|
||||
pub(super) applied_rollouts: usize,
|
||||
pub(super) skipped_rollouts: Vec<PathBuf>,
|
||||
pub(super) sqlite_updates: SqliteUpdateCounts,
|
||||
}
|
||||
|
||||
// A file may be reclassified after scanning. In particular, the general file
|
||||
// writer skips concurrently changed rollouts; that must not leave their old IDs
|
||||
// eligible for a catalog insert or a provider update in this transaction.
|
||||
fn validate_rollout_classification(rollouts: &RolloutScan) -> Result<()> {
|
||||
let mut paths = rollouts.provider_candidate_paths.clone();
|
||||
for change in &rollouts.changes {
|
||||
let original_is_internal = if let Some(snapshot) = &change.streamed {
|
||||
!rollout_path_has_syncable_identity(&snapshot.original_path)?
|
||||
} else {
|
||||
rollout_text_is_internal(&change.original_text)
|
||||
};
|
||||
if original_is_internal {
|
||||
return Err(CodexxError::Config(
|
||||
"内部会话不能同步为普通会话。".to_string(),
|
||||
));
|
||||
}
|
||||
paths.insert(change.path.clone());
|
||||
}
|
||||
for path in paths {
|
||||
if !rollout_path_has_syncable_identity(&path)? {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话类型已变化,已停止同步;请重新检查会话:{}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expected_rollout_hashes(rollouts: &RolloutScan) -> Result<HashMap<PathBuf, [u8; 32]>> {
|
||||
let mut expected = HashMap::new();
|
||||
for path in &rollouts.provider_candidate_paths {
|
||||
let hash = rollouts.verified_rollout_hashes.get(path).ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"缺少会话文件验证快照,已停止同步:{}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
expected.insert(path.clone(), *hash);
|
||||
}
|
||||
for change in &rollouts.changes {
|
||||
let original_hash = change.original_hash();
|
||||
if expected
|
||||
.get(&change.path)
|
||||
.is_some_and(|hash| *hash != original_hash)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件改写快照与扫描结果不一致,已停止同步:{}",
|
||||
change.path.display()
|
||||
)));
|
||||
}
|
||||
// A planned change already carries the scan's verified original snapshot.
|
||||
expected.insert(change.path.clone(), original_hash);
|
||||
}
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
fn validate_rollout_snapshots(expected: &HashMap<PathBuf, [u8; 32]>) -> Result<()> {
|
||||
for (path, expected_hash) in expected {
|
||||
if hash_rollout_file(path).map_err(|error| io_err(path, error))? != *expected_hash {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件已发生变化,已停止同步;请重新检查会话:{}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
@@ -424,8 +515,35 @@ where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
if !rollouts.blocked_failures.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话扫描未完成,已停止同步:{}",
|
||||
rollouts.blocked_failures.join(";")
|
||||
)));
|
||||
}
|
||||
journal.expected_rollout_hashes = expected_rollout_hashes(rollouts)?;
|
||||
hook(MutationPoint::BeforeRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let (applied_rollouts, skipped_rollouts) =
|
||||
apply_session_changes_with_hook(&rollouts.changes, |index| {
|
||||
hook(MutationPoint::BeforeEachRolloutMutation(index))
|
||||
})?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
for change in &journal.applied_rollouts {
|
||||
journal
|
||||
.expected_rollout_hashes
|
||||
.insert(change.path.clone(), change.next_hash());
|
||||
}
|
||||
if !skipped_rollouts.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"有 {} 个会话文件被占用或已发生变化,已停止同步;请退出 Codex 后重新检查会话。",
|
||||
skipped_rollouts.len()
|
||||
)));
|
||||
}
|
||||
hook(MutationPoint::AfterRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
apply_sqlite_updates(
|
||||
pending_sqlite,
|
||||
rollouts,
|
||||
@@ -436,7 +554,6 @@ where
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
skipped_rollouts,
|
||||
sqlite_updates,
|
||||
})
|
||||
})();
|
||||
|
||||
@@ -2,13 +2,16 @@ use super::*;
|
||||
use crate::sessions::backup::provider_sync_backup_root;
|
||||
use crate::sessions::sync::sync_sessions_provider_with_hook;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const LARGE_ROLLOUT_TEST_BYTES: u64 = 33 * 1024 * 1024;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -73,6 +76,633 @@ fn sqlite_quick_check(path: &Path) -> String {
|
||||
.expect("run quick check")
|
||||
}
|
||||
|
||||
struct RolloutFailureFixture {
|
||||
codex_dir: PathBuf,
|
||||
ids: [&'static str; 2],
|
||||
rollouts: [PathBuf; 2],
|
||||
originals: [Vec<u8>; 2],
|
||||
database: PathBuf,
|
||||
catalog: PathBuf,
|
||||
}
|
||||
|
||||
impl RolloutFailureFixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let codex_dir = temp_dir(name);
|
||||
let ids = [
|
||||
"019f6000-0000-7000-8000-000000000991",
|
||||
"019f6000-0000-7000-8000-000000000992",
|
||||
];
|
||||
let rollouts = ids.map(|id| codex_dir.join(format!("sessions/rollout-test-{id}.jsonl")));
|
||||
let originals = std::array::from_fn(|index| write_rollout(&rollouts[index], ids[index]));
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, ids[0], &rollouts[0]);
|
||||
Connection::open(&database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
|
||||
(ids[1], rollouts[1].display().to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
fs::create_dir_all(catalog.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(&catalog).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE local_thread_catalog (
|
||||
host_id TEXT NOT NULL, thread_id TEXT NOT NULL, model_provider TEXT NOT NULL,
|
||||
PRIMARY KEY (host_id, thread_id));
|
||||
CREATE TABLE local_thread_catalog_metadata (
|
||||
id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL);
|
||||
INSERT INTO local_thread_catalog_metadata VALUES (1, 7);",
|
||||
)
|
||||
.unwrap();
|
||||
for id in ids {
|
||||
conn.execute(
|
||||
"INSERT INTO local_thread_catalog VALUES ('local', ?1, 'openai')",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Self {
|
||||
codex_dir,
|
||||
ids,
|
||||
rollouts,
|
||||
originals,
|
||||
database,
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&self) -> RolloutScan {
|
||||
let changes = (0..2)
|
||||
.map(|index| {
|
||||
let original_text = String::from_utf8(self.originals[index].clone()).unwrap();
|
||||
SessionFileChange {
|
||||
path: self.rollouts[index].clone(),
|
||||
next_text: original_text.replace("\"openai\"", "\"custom\""),
|
||||
original_text,
|
||||
original_mtime: None,
|
||||
streamed: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
RolloutScan {
|
||||
changes,
|
||||
provider_candidate_paths: self.rollouts.iter().cloned().collect(),
|
||||
verified_rollout_hashes: (0..2)
|
||||
.map(|index| {
|
||||
(
|
||||
self.rollouts[index].clone(),
|
||||
Sha256::digest(&self.originals[index]).into(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..RolloutScan::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_rollouts_to_target_provider(&mut self) {
|
||||
for index in 0..2 {
|
||||
self.originals[index] = String::from_utf8(self.originals[index].clone())
|
||||
.unwrap()
|
||||
.replace("\"openai\"", "\"custom\"")
|
||||
.into_bytes();
|
||||
fs::write(&self.rollouts[index], &self.originals[index]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_provider(&self, id: &str) -> String {
|
||||
Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT model_provider FROM local_thread_catalog WHERE thread_id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_databases_unchanged(&self) {
|
||||
for id in self.ids {
|
||||
assert_eq!(thread_provider(&self.database, id), "openai");
|
||||
assert_eq!(self.catalog_provider(id), "openai");
|
||||
}
|
||||
let (rows, revision): (i64, i64) = Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT (SELECT COUNT(*) FROM local_thread_catalog),
|
||||
(SELECT catalog_revision FROM local_thread_catalog_metadata WHERE id = 1)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((rows, revision), (2, 7));
|
||||
}
|
||||
|
||||
fn sync_with_hook<F>(&self, hook: F) -> Result<super::super::types::SessionSyncResult>
|
||||
where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
sync_sessions_provider_with_hook(Some(self.codex_dir.display().to_string()), None, hook)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RolloutFailureFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.codex_dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_rollout_scan_aborts_before_hooks_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-blocked-scan");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts
|
||||
.blocked_failures
|
||||
.push("会话文件读取失败,未完成扫描".to_string());
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("blocked scan must fail");
|
||||
assert!(error.to_string().contains("读取失败"));
|
||||
assert!(!hook_called);
|
||||
assert!(journal.applied_rollouts.is_empty());
|
||||
assert!(rollback_mutation(&journal, &mut pending).is_empty());
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_rollout_change_is_rejected_before_file_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-skip-rollback");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-new-data\"}}\n",
|
||||
);
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("any skipped rollout must abort the full sync");
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_rollout_after_first_write_rolls_back_and_never_updates_indexes() {
|
||||
let fixture = RolloutFailureFixture::new("streamed-skip-after-write");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-external-data\"}}\n",
|
||||
);
|
||||
let mut first_write_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
first_write_observed = fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a skipped second rollout must fail and roll back the first write");
|
||||
assert!(first_write_observed);
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_scan_aborts_without_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("unreadable full rollout must fail the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rollout_atomic_write_failure_restores_earlier_files_without_database_updates() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut fixture = RolloutFailureFixture::new("streaming-write-failure");
|
||||
let blocked_parent = fixture.codex_dir.join("sessions/zz-write-blocked");
|
||||
fs::create_dir_all(&blocked_parent).unwrap();
|
||||
let second_path = blocked_parent.join(fixture.rollouts[1].file_name().unwrap());
|
||||
fs::rename(&fixture.rollouts[1], &second_path).unwrap();
|
||||
fixture.rollouts[1] = second_path;
|
||||
Connection::open(&fixture.database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE threads SET rollout_path = ?1 WHERE id = ?2",
|
||||
(fixture.rollouts[1].display().to_string(), fixture.ids[1]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut reached_file_mutation = false;
|
||||
let result = fixture.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
reached_file_mutation = true;
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o500)).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
// Always restore the temporary fixture permissions before assertions/drop.
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let error = result.expect_err("temporary rollout write permission failure must fail sync");
|
||||
assert!(
|
||||
reached_file_mutation,
|
||||
"failure must occur after scan and backup"
|
||||
);
|
||||
assert!(error.to_string().contains("IO error"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_provider_rewrite_after_mutation_aborts_without_database_or_catalog_changes() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-provider-race");
|
||||
let external = fixture.originals[1].clone();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
assert!(fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
assert!(fs::read_to_string(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("ordinary provider change after writes must fail sync");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_snapshot_is_rechecked_before_each_database_commit() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-between-commits");
|
||||
let mut first_committed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(0) {
|
||||
first_committed = true;
|
||||
assert!(
|
||||
thread_provider(&fixture.database, fixture.ids[0]) == "custom"
|
||||
|| fixture.catalog_provider(fixture.ids[0]) == "custom"
|
||||
);
|
||||
fs::write(&fixture.rollouts[1], &fixture.originals[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("new rollout data before the second commit must abort and restore the first");
|
||||
assert!(first_committed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_scan_is_not_overwritten_or_reported_as_success() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-before-mutation");
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("concurrent growth must invalidate the verified snapshot");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_file_writes_preserves_external_data_and_rolls_back_other_files() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-after-mutation");
|
||||
let marker = b"keep-external-growth";
|
||||
let marker_offset = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let enlarged_len = marker_offset + marker.len() as u64;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
let mut external = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
external.set_len(enlarged_len).unwrap();
|
||||
external.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
external.write_all(marker).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("growth after writes must prevent all database commits");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut preserved = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
preserved.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
let mut restored_marker = vec![0; marker.len()];
|
||||
preserved.read_exact(&mut restored_marker).unwrap();
|
||||
assert_eq!(restored_marker, marker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_writes_restores_other_files_and_preserves_external_content() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure-after-writes");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("full snapshot read failure after writes must abort the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_without_verified_hash_is_rejected_before_mutation() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-unverified-candidate");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts.changes.clear();
|
||||
rollouts.verified_rollout_hashes.clear();
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("unverified candidate must not take a fresh mutable file as its baseline");
|
||||
assert!(
|
||||
error.to_string().contains("缺少会话文件验证快照"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!hook_called);
|
||||
fixture.assert_databases_unchanged();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidates_normally_repair_databases_without_rewriting_rollouts() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-normal-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("matched verified logs permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
thread_provider(&fixture.database, fixture.ids[index]),
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[index]), "custom");
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_unchanged_candidate_repairs_databases_without_rewriting_the_file() {
|
||||
let mut fixture = RolloutFailureFixture::new("streamed-large-matched-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let event = b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"unchanged large history\"}}\n";
|
||||
let chunk = event.repeat(512);
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
let mut original_len = file.metadata().unwrap().len();
|
||||
while original_len <= LARGE_ROLLOUT_TEST_BYTES {
|
||||
file.write_all(&chunk).unwrap();
|
||||
original_len += chunk.len() as u64;
|
||||
}
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
let original_hash = hash_rollout_file(&fixture.rollouts[1]).unwrap();
|
||||
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("large matched files permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for id in fixture.ids {
|
||||
assert_eq!(thread_provider(&fixture.database, id), "custom");
|
||||
assert_eq!(fixture.catalog_provider(id), "custom");
|
||||
}
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
original_len
|
||||
);
|
||||
assert_eq!(
|
||||
hash_rollout_file(&fixture.rollouts[1]).unwrap(),
|
||||
original_hash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_provider_changes_before_or_after_mutation_block_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-provider-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must not permit database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_growth_before_or_after_mutation_blocks_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-growth-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must prevent database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut external = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
let mut prefix = vec![0; fixture.originals[1].len()];
|
||||
external.read_exact(&mut prefix).unwrap();
|
||||
assert_eq!(prefix, fixture.originals[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_change_after_final_commit_rolls_back_databases_and_preserves_external_log() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-final-commit-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let mut last_commit_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(1) {
|
||||
last_commit_observed = true;
|
||||
assert_eq!(thread_provider(&fixture.database, fixture.ids[0]), "custom");
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[0]), "custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("the final commit hook must not bypass the complete snapshot validation");
|
||||
assert!(last_commit_observed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_update_failure_rolls_back_every_open_database_and_jsonl() {
|
||||
let codex_dir = temp_dir("multi-sqlite-update-failure");
|
||||
@@ -452,3 +1082,89 @@ fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
|
||||
drop(wal_guard);
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_reclassification_before_mutation_aborts_without_registering_or_rewriting() {
|
||||
for existing_provider in ["openai", "custom"] {
|
||||
let codex_dir = temp_dir("internal-before-mutation");
|
||||
let id = "019f6000-0000-7000-8000-000000000980";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let existing = String::from_utf8(write_rollout(&rollout, id))
|
||||
.expect("UTF-8 fixture")
|
||||
.replace("openai", existing_provider);
|
||||
fs::write(&rollout, existing).expect("set current rollout provider");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
let internal = format!(
|
||||
"{}\n",
|
||||
serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id":id, "model_provider":existing_provider, "source":{"internal":"guardian"}
|
||||
}})
|
||||
);
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
None,
|
||||
|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::write(&rollout, &internal).expect("reclassify rollout during sync");
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.expect_err("reclassification must abort sync");
|
||||
assert!(error.to_string().contains("会话类型已变化"), "{error}");
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&rollout).expect("read internal rollout"),
|
||||
internal
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove race fixture");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_reclassification_after_file_writes_rolls_back_other_files_and_all_databases() {
|
||||
let codex_dir = temp_dir("internal-after-rollout-mutation");
|
||||
let parent = "019f6000-0000-7000-8000-000000000981";
|
||||
let child = "019f6000-0000-7000-8000-000000000982";
|
||||
let parent_rollout = codex_dir.join(format!("sessions/rollout-test-{parent}.jsonl"));
|
||||
let child_rollout = codex_dir.join(format!("sessions/rollout-test-{child}.jsonl"));
|
||||
let original_parent = write_rollout(&parent_rollout, parent);
|
||||
write_rollout(&child_rollout, child);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, parent, &parent_rollout);
|
||||
Connection::open(&database)
|
||||
.expect("open fixture index")
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
|
||||
(child, child_rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert second thread");
|
||||
let internal = format!(
|
||||
"{}\n",
|
||||
serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id":child, "model_provider":"openai", "source":{"internal":"guardian"}
|
||||
}})
|
||||
);
|
||||
let error =
|
||||
sync_sessions_provider_with_hook(Some(codex_dir.display().to_string()), None, |point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
fs::write(&child_rollout, &internal)
|
||||
.expect("publish internal metadata during sync");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("reclassification must prevent database commits");
|
||||
assert!(error.to_string().contains("会话类型已变化"), "{error}");
|
||||
assert_eq!(thread_provider(&database, parent), "openai");
|
||||
assert_eq!(thread_provider(&database, child), "openai");
|
||||
assert_eq!(
|
||||
fs::read(&parent_rollout).expect("read restored parent"),
|
||||
original_parent
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&child_rollout).expect("preserve newer internal source"),
|
||||
internal
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove rollback fixture");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -23,6 +25,10 @@ pub(crate) struct SessionPreview {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SessionSyncStatus {
|
||||
pub(crate) codex_dir: String,
|
||||
#[serde(default)]
|
||||
pub(crate) directory_identities: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub(crate) pin_scope_key: Option<String>,
|
||||
pub(crate) target_provider: String,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
@@ -59,11 +65,21 @@ pub(crate) struct RolloutScan {
|
||||
pub(crate) mismatched_rollouts: usize,
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) provider_candidate_paths: HashSet<PathBuf>,
|
||||
/// SHA-256 of every completely validated candidate's original JSONL. Keep
|
||||
/// matched rollouts covered without retaining another copy of their text.
|
||||
pub(crate) verified_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
pub(crate) cwd_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
/// Non-user threads identified from their own rollout metadata. Retained
|
||||
/// even when the rollout is excluded from provider synchronization.
|
||||
pub(crate) internal_thread_ids: HashSet<String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
/// Incomplete user-rollout scans that must block Provider/index mutations,
|
||||
/// including unindexed files that would otherwise become only warnings.
|
||||
pub(crate) blocked_failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -72,6 +88,24 @@ pub(crate) struct SessionFileChange {
|
||||
pub(crate) original_text: String,
|
||||
pub(crate) next_text: String,
|
||||
pub(crate) original_mtime: Option<SystemTime>,
|
||||
// Production snapshots live on disk; inline text is retained for small fixtures.
|
||||
pub(super) streamed: Option<Arc<super::rollout_stream::StreamedSnapshot>>,
|
||||
}
|
||||
|
||||
impl SessionFileChange {
|
||||
pub(super) fn original_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.original_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.original_hash,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn next_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.next_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.next_hash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::mcp::{
|
||||
commit_mcp_transaction_with_config, document_bytes, is_valid_mcp_config, json_to_toml_item,
|
||||
mcp_configs_equal, save_managed_mcp_on_connection,
|
||||
};
|
||||
use super::skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, read_skill_metadata,
|
||||
sanitize_dir_name,
|
||||
};
|
||||
use super::types::{SkillsMcpActionResult, SkillsMcpExportResult};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, parse_toml_document};
|
||||
use crate::live_config::{acquire_live_config_lock, read_file_snapshot, text_from_snapshot};
|
||||
use crate::paths::{app_home, normalized_path_scope};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
const MANIFEST: &str = "codex-x-archive.json";
|
||||
const FORMAT: &str = "codex-x-skills-mcp";
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
skills: Vec<SkillEntry>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Vec<McpEntry>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct SkillEntry {
|
||||
directory: String,
|
||||
path: String,
|
||||
enabled: bool,
|
||||
note: Option<String>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct McpEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
config: Value,
|
||||
enabled: bool,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> CodexxError {
|
||||
CodexxError::Config(message.into())
|
||||
}
|
||||
fn zip_error(error: impl std::fmt::Display) -> CodexxError {
|
||||
invalid(format!("无法读取或写入 ZIP:{error}"))
|
||||
}
|
||||
fn unique_name(prefix: &str) -> String {
|
||||
format!(
|
||||
".{prefix}-{}-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new(parent: &Path) -> Result<Self> {
|
||||
ensure_directory(parent)?;
|
||||
let path = parent.join(unique_name("codex-x-archive"));
|
||||
fs::create_dir(&path).map_err(|error| io_err(&path, error))?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Reject Windows traversal/drive names on every OS too, so exported bundles can
|
||||
// safely move between machines. Links and special files are never extracted.
|
||||
fn safe_relative(name: &str) -> Result<PathBuf> {
|
||||
if name.is_empty() || name.contains('\\') || name.contains(':') || name.contains('\0') {
|
||||
return Err(invalid("ZIP 中含有不安全的文件路径"));
|
||||
}
|
||||
let path = Path::new(name);
|
||||
if path
|
||||
.components()
|
||||
.any(|part| !matches!(part, Component::Normal(_)))
|
||||
{
|
||||
return Err(invalid("ZIP 中含有不安全的文件路径"));
|
||||
}
|
||||
for part in name.trim_end_matches('/').split('/') {
|
||||
let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
|
||||
if part.is_empty()
|
||||
|| part.ends_with([' ', '.'])
|
||||
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (stem.len() == 4
|
||||
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.as_bytes()[3].is_ascii_digit())
|
||||
{
|
||||
return Err(invalid("ZIP 中含有无法跨平台安全使用的文件路径"));
|
||||
}
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
fn safe_directory(name: &str) -> Result<()> {
|
||||
let path = safe_relative(name)?;
|
||||
if path.components().count() != 1 || name.starts_with('.') {
|
||||
return Err(invalid("归档中的 Skill 目录名无效"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract<R: Read + Seek>(reader: R, destination: &Path) -> Result<()> {
|
||||
let mut archive = zip::ZipArchive::new(reader).map_err(zip_error)?;
|
||||
let mut seen = HashSet::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index).map_err(zip_error)?;
|
||||
let name = entry.name().trim_end_matches('/');
|
||||
let relative = safe_relative(name)?;
|
||||
let mode = entry.unix_mode().unwrap_or(0) & 0o170000;
|
||||
if mode != 0 && mode != 0o100000 && mode != 0o040000 {
|
||||
return Err(invalid("ZIP 不支持符号链接或特殊文件"));
|
||||
}
|
||||
if !seen.insert(name.to_ascii_lowercase()) {
|
||||
return Err(invalid("ZIP 中含有重复文件路径"));
|
||||
}
|
||||
let output = destination.join(relative);
|
||||
if entry.is_dir() {
|
||||
ensure_directory(&output)?;
|
||||
} else {
|
||||
ensure_directory(output.parent().ok_or_else(|| invalid("ZIP 路径无效"))?)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&output)
|
||||
.map_err(|error| io_err(&output, error))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|error| io_err(&output, error))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = entry.unix_mode() {
|
||||
fs::set_permissions(&output, fs::Permissions::from_mode(mode & 0o777))
|
||||
.map_err(|error| io_err(&output, error))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_entries(root: &Path, current: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(current).map_err(|error| io_err(current, error))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(invalid("Skill 包含符号链接,请先替换为实际文件再导出"));
|
||||
}
|
||||
if metadata.is_file() {
|
||||
output.push(
|
||||
current
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| invalid("Skill 文件路径无效"))?
|
||||
.to_path_buf(),
|
||||
);
|
||||
} else if metadata.is_dir() {
|
||||
if current != root {
|
||||
output.push(
|
||||
current
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| invalid("Skill 目录路径无效"))?
|
||||
.to_path_buf(),
|
||||
);
|
||||
}
|
||||
for entry in fs::read_dir(current).map_err(|error| io_err(current, error))? {
|
||||
collect_entries(
|
||||
root,
|
||||
&entry.map_err(|error| io_err(current, error))?.path(),
|
||||
output,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
return Err(invalid("Skill 包含不支持的特殊文件"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn directory_digest(path: &Path) -> Result<Vec<u8>> {
|
||||
let mut files = Vec::new();
|
||||
collect_entries(path, path, &mut files)?;
|
||||
files.sort();
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
for relative in files {
|
||||
let name = relative.to_string_lossy().replace('\\', "/");
|
||||
digest.update((name.len() as u64).to_le_bytes());
|
||||
digest.update(name.as_bytes());
|
||||
let file_path = path.join(relative);
|
||||
if file_path.is_dir() {
|
||||
digest.update(b"directory");
|
||||
continue;
|
||||
}
|
||||
digest.update(b"file");
|
||||
let mut file = File::open(&file_path).map_err(|error| io_err(&file_path, error))?;
|
||||
digest.update(
|
||||
file.metadata()
|
||||
.map_err(|error| io_err(&file_path, error))?
|
||||
.len()
|
||||
.to_le_bytes(),
|
||||
);
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(&file_path, error))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
Ok(digest.finalize().to_vec())
|
||||
}
|
||||
fn write_archive(path: &Path, manifest: &Manifest, sources: &[PathBuf]) -> Result<()> {
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.large_file(true);
|
||||
writer.start_file(MANIFEST, options).map_err(zip_error)?;
|
||||
serde_json::to_writer_pretty(&mut writer, manifest).map_err(|_| invalid("无法写入归档清单"))?;
|
||||
for (entry, source) in manifest.skills.iter().zip(sources) {
|
||||
let mut files = Vec::new();
|
||||
collect_entries(source, source, &mut files)?;
|
||||
files.sort();
|
||||
for relative in files {
|
||||
let relative_name = relative.to_string_lossy().replace('\\', "/");
|
||||
safe_relative(&relative_name)?;
|
||||
let source_file = source.join(&relative);
|
||||
let mut options = options;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
options = options.unix_permissions(
|
||||
fs::metadata(&source_file)
|
||||
.map_err(|error| io_err(&source_file, error))?
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
);
|
||||
}
|
||||
if source_file.is_dir() {
|
||||
writer
|
||||
.add_directory(format!("{}/{}/", entry.path, relative_name), options)
|
||||
.map_err(zip_error)?;
|
||||
continue;
|
||||
}
|
||||
writer
|
||||
.start_file(format!("{}/{}", entry.path, relative_name), options)
|
||||
.map_err(zip_error)?;
|
||||
let mut input =
|
||||
File::open(&source_file).map_err(|error| io_err(&source_file, error))?;
|
||||
std::io::copy(&mut input, &mut writer).map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(zip_error)?
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destination_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(invalid("请选择普通 ZIP 文件路径,不能覆盖符号链接或目录"));
|
||||
}
|
||||
let mut file = File::open(path).map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
Ok(Some(digest.finalize().to_vec()))
|
||||
}
|
||||
|
||||
fn write_archive_atomic<BeforeReplace: FnOnce() -> Result<()>>(
|
||||
output: &Path,
|
||||
manifest: &Manifest,
|
||||
sources: &[PathBuf],
|
||||
before_replace: BeforeReplace,
|
||||
) -> Result<()> {
|
||||
let initial = destination_snapshot(output)?;
|
||||
let parent = output
|
||||
.parent()
|
||||
.ok_or_else(|| invalid("请选择有效的保存目录"))?;
|
||||
let temporary = parent.join(unique_name("codex-x-export"));
|
||||
let result = (|| {
|
||||
write_archive(&temporary, manifest, sources)?;
|
||||
before_replace()?;
|
||||
if destination_snapshot(output)? != initial {
|
||||
return Err(invalid(
|
||||
"保存位置的文件已被其他程序修改,请重新选择位置再导出",
|
||||
));
|
||||
}
|
||||
fs::rename(&temporary, output).map_err(|error| io_err(output, error))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_export_destination(output: &Path, codex_dir: &Path) -> Result<PathBuf> {
|
||||
if !output
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
|
||||
{
|
||||
return Err(invalid("请将导出文件保存为 .zip 格式"));
|
||||
}
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| parent.is_dir())
|
||||
.ok_or_else(|| invalid("请选择有效的保存目录"))?;
|
||||
let canonical_parent = fs::canonicalize(parent).map_err(|error| io_err(parent, error))?;
|
||||
for protected in [codex_dir.to_path_buf(), app_home()?] {
|
||||
let canonical = fs::canonicalize(&protected).unwrap_or(protected);
|
||||
if canonical_parent.starts_with(canonical) {
|
||||
return Err(invalid(
|
||||
"请将 ZIP 保存到下载、桌面等目录,不要放入 Codex 配置或应用数据目录",
|
||||
));
|
||||
}
|
||||
}
|
||||
destination_snapshot(output)?;
|
||||
Ok(canonical_parent)
|
||||
}
|
||||
|
||||
pub(crate) fn export_skills_mcp_archive_inner(
|
||||
config_dir: Option<String>,
|
||||
kind: String,
|
||||
destination: String,
|
||||
) -> Result<SkillsMcpExportResult> {
|
||||
if !matches!(kind.as_str(), "skills" | "mcp") {
|
||||
return Err(invalid("请选择导出 Skills 或 MCP"));
|
||||
}
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
let mut manifest = Manifest {
|
||||
format: FORMAT.to_owned(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
};
|
||||
let mut sources = Vec::new();
|
||||
if kind == "skills" {
|
||||
for skill in state.skills {
|
||||
safe_directory(&skill.directory)?;
|
||||
manifest.skills.push(SkillEntry {
|
||||
path: format!("skills/{}", skill.directory),
|
||||
directory: skill.directory,
|
||||
enabled: skill.enabled,
|
||||
note: skill.note,
|
||||
});
|
||||
sources.push(PathBuf::from(skill.path));
|
||||
}
|
||||
} else {
|
||||
for mcp in state.mcp_servers {
|
||||
manifest.mcp_servers.push(McpEntry {
|
||||
id: mcp.id,
|
||||
name: mcp.name,
|
||||
config: mcp.config_json,
|
||||
enabled: mcp.enabled,
|
||||
note: mcp.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
if manifest.skills.is_empty() && manifest.mcp_servers.is_empty() {
|
||||
return Err(invalid("当前没有可导出的内容"));
|
||||
}
|
||||
let output = PathBuf::from(destination);
|
||||
let canonical_parent = validate_export_destination(&output, Path::new(&state.codex_dir))?;
|
||||
for source in &sources {
|
||||
let canonical_source = fs::canonicalize(source).map_err(|error| io_err(source, error))?;
|
||||
if canonical_parent.starts_with(canonical_source) {
|
||||
return Err(invalid("请将 ZIP 保存在 Skill 目录之外"));
|
||||
}
|
||||
}
|
||||
write_archive_atomic(&output, &manifest, &sources, || Ok(()))?;
|
||||
Ok(SkillsMcpExportResult {
|
||||
path: output.display().to_string(),
|
||||
exported_skills: manifest.skills.len(),
|
||||
exported_mcp: manifest.mcp_servers.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_skill_archive_path_inner(
|
||||
config_dir: Option<String>,
|
||||
path: String,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let path = Path::new(&path);
|
||||
let file = File::open(path).map_err(|error| io_err(path, error))?;
|
||||
install_archive_reader(
|
||||
config_dir,
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("skills.zip")
|
||||
.to_owned(),
|
||||
file,
|
||||
)
|
||||
}
|
||||
|
||||
fn find_skill_roots(path: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if path.join("SKILL.md").is_file() {
|
||||
output.push(path.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(path).map_err(|error| io_err(path, error))? {
|
||||
let entry = entry.map_err(|error| io_err(path, error))?;
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.is_dir()
|
||||
{
|
||||
find_skill_roots(&entry.path(), output)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn manifest_from_extracted(path: &Path, file_name: &str) -> Result<Manifest> {
|
||||
let manifest_path = path.join(MANIFEST);
|
||||
if manifest_path.exists() {
|
||||
let file = File::open(&manifest_path).map_err(|error| io_err(&manifest_path, error))?;
|
||||
// This limits only metadata, not ZIP contents or Skill file sizes.
|
||||
let manifest: Manifest = serde_json::from_reader(file.take(8 * 1024 * 1024))
|
||||
.map_err(|_| invalid("Codex-X 归档清单无效或过大"))?;
|
||||
if manifest.format != FORMAT || manifest.version != 1 {
|
||||
return Err(invalid("暂不支持此归档版本,请更新 Codex-X"));
|
||||
}
|
||||
return Ok(manifest);
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
find_skill_roots(path, &mut roots)?;
|
||||
let mut manifest = Manifest {
|
||||
format: FORMAT.to_owned(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
};
|
||||
for root in roots {
|
||||
let fallback = if root == path {
|
||||
file_name.trim_end_matches(".zip")
|
||||
} else {
|
||||
root.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(file_name.trim_end_matches(".zip"))
|
||||
};
|
||||
let (name, _) = read_skill_metadata(&root, fallback);
|
||||
let relative = root
|
||||
.strip_prefix(path)
|
||||
.map_err(|_| invalid("Skill 路径无效"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
manifest.skills.push(SkillEntry {
|
||||
directory: sanitize_dir_name(&name, "skill"),
|
||||
path: relative,
|
||||
enabled: true,
|
||||
note: None,
|
||||
});
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub(super) fn install_archive_reader<R: Read + Seek>(
|
||||
config_dir: Option<String>,
|
||||
file_name: String,
|
||||
reader: R,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let temporary = TempDir::new(&app_home()?.join("tmp"))?;
|
||||
extract(reader, &temporary.0)?;
|
||||
let manifest = manifest_from_extracted(&temporary.0, &file_name)?;
|
||||
let (imported_skills, imported_mcp, skipped) =
|
||||
import_manifest(config_dir.clone(), &temporary.0, manifest)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp,
|
||||
message: format!(
|
||||
"已导入 {imported_skills} 个 Skills、{imported_mcp} 个 MCP{}",
|
||||
if skipped > 0 {
|
||||
format!(",跳过 {skipped} 个已存在的项目")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
),
|
||||
state: build_skills_mcp_state_inner(config_dir)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn import_manifest(
|
||||
config_dir: Option<String>,
|
||||
root: &Path,
|
||||
manifest: Manifest,
|
||||
) -> Result<(usize, usize, usize)> {
|
||||
if manifest.skills.is_empty() && manifest.mcp_servers.is_empty() {
|
||||
return Err(invalid("ZIP 中没有可导入的 Skills 或 MCP"));
|
||||
}
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let current = build_skills_mcp_state_inner(config_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let mut document = parse_toml_document(&cfg, &text_from_snapshot(&cfg, before.as_deref())?)?;
|
||||
let mut skill_ids = HashSet::new();
|
||||
let mut mcp_ids = HashSet::new();
|
||||
let existing_skills: HashMap<_, _> = current
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| (skill.directory.as_str(), skill))
|
||||
.collect();
|
||||
let existing_mcp: HashMap<_, _> = current
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.map(|mcp| (mcp.id.as_str(), mcp))
|
||||
.collect();
|
||||
let mut skill_plan = Vec::new();
|
||||
let mut mcp_plan = Vec::new();
|
||||
let mut skipped = 0;
|
||||
for skill in manifest.skills {
|
||||
safe_directory(&skill.directory)?;
|
||||
if !skill_ids.insert(sanitize_dir_name(&skill.directory, "skill")) {
|
||||
return Err(invalid("归档中存在同名 Skill"));
|
||||
}
|
||||
let relative = if skill.path.is_empty() {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
safe_relative(&skill.path)?
|
||||
};
|
||||
let source = root.join(relative);
|
||||
if !source.join("SKILL.md").is_file() {
|
||||
return Err(invalid(format!("Skill {} 缺少 SKILL.md", skill.directory)));
|
||||
}
|
||||
if let Some(existing) = existing_skills.get(skill.directory.as_str()) {
|
||||
if directory_digest(&source)? == directory_digest(Path::new(&existing.path))? {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(invalid(format!(
|
||||
"Skill「{}」已存在且内容不同。请先重命名或移除已有项目,导入不会覆盖它。",
|
||||
skill.directory
|
||||
)));
|
||||
}
|
||||
let parent = if skill.enabled {
|
||||
codex_skills_dir(&codex_dir)
|
||||
} else {
|
||||
disabled_skills_dir()?
|
||||
};
|
||||
let destination = parent.join(&skill.directory);
|
||||
if destination.exists() {
|
||||
return Err(invalid(format!(
|
||||
"Skill 目录「{}」已存在,未覆盖",
|
||||
skill.directory
|
||||
)));
|
||||
}
|
||||
skill_plan.push((skill, source, destination));
|
||||
}
|
||||
for mcp in manifest.mcp_servers {
|
||||
if mcp.id.trim().is_empty()
|
||||
|| !mcp_ids.insert(mcp.id.clone())
|
||||
|| !is_valid_mcp_config(&mcp.config)
|
||||
{
|
||||
return Err(invalid("归档中存在无效或重复的 MCP 配置"));
|
||||
}
|
||||
if let Some(existing) = existing_mcp.get(mcp.id.as_str()) {
|
||||
if mcp_configs_equal(&existing.config_json, &mcp.config) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(invalid(format!(
|
||||
"MCP「{}」已存在且配置不同。请先重命名或移除已有项目,导入不会覆盖它。",
|
||||
mcp.name
|
||||
)));
|
||||
}
|
||||
// A malformed live entry is still user data; do not overwrite it.
|
||||
if document
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table())
|
||||
.is_some_and(|table| table.contains_key(&mcp.id))
|
||||
{
|
||||
return Err(invalid(format!("MCP「{}」已存在,未覆盖", mcp.name)));
|
||||
}
|
||||
mcp_plan.push(mcp);
|
||||
}
|
||||
let mut connection = open_db()?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let scope = normalized_path_scope(&codex_dir);
|
||||
let save_note = |kind: &str, id: &str, note: &Option<String>| -> Result<()> {
|
||||
if let Some(note) = note.as_deref().filter(|note| !note.trim().is_empty()) {
|
||||
if note.chars().count() > super::SKILLS_MCP_NOTE_MAX_CHARS {
|
||||
return Err(invalid("归档中的备注过长"));
|
||||
}
|
||||
transaction.execute("INSERT INTO skills_mcp_notes (codex_dir,item_kind,item_id,note,updated_at) VALUES (?1,?2,?3,?4,?5) ON CONFLICT(codex_dir,item_kind,item_id) DO NOTHING", params![scope, kind, id, note, now_rfc3339()]).map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for mcp in &mcp_plan {
|
||||
save_managed_mcp_on_connection(&transaction, &mcp.id, &mcp.name, &mcp.config, mcp.enabled)?;
|
||||
if mcp.enabled {
|
||||
ensure_table(document.as_table_mut(), "mcp_servers")?
|
||||
.insert(&mcp.id, json_to_toml_item(&mcp.config));
|
||||
}
|
||||
save_note("mcp", &mcp.id, &mcp.note)?;
|
||||
}
|
||||
for (skill, _, _) in &skill_plan {
|
||||
save_note(
|
||||
"skill",
|
||||
&sanitize_dir_name(&skill.directory, "skill"),
|
||||
&skill.note,
|
||||
)?;
|
||||
}
|
||||
let mut created = Vec::new();
|
||||
let apply = (|| -> Result<()> {
|
||||
for (_, source, destination) in &skill_plan {
|
||||
let staging = TempDir::new(
|
||||
destination
|
||||
.parent()
|
||||
.ok_or_else(|| invalid("Skill 目标目录无效"))?,
|
||||
)?;
|
||||
copy_dir_recursive(source, &staging.0)?;
|
||||
if destination.exists() {
|
||||
return Err(invalid("导入期间出现同名 Skill,已停止且未覆盖"));
|
||||
}
|
||||
fs::rename(&staging.0, destination).map_err(|error| io_err(destination, error))?;
|
||||
created.push(destination.clone());
|
||||
}
|
||||
let after = document_bytes(before.as_deref(), &document);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
&codex_dir,
|
||||
before,
|
||||
after,
|
||||
"import-skills-mcp-zip",
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
})();
|
||||
if apply.is_err() {
|
||||
for path in created {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
apply?;
|
||||
Ok((skill_plan.len(), mcp_plan.len(), skipped))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
Manifest {
|
||||
format: FORMAT.into(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
}
|
||||
}
|
||||
fn test_dir() -> TempDir {
|
||||
TempDir::new(&std::env::temp_dir().join("codex-x-archive-tests")).unwrap()
|
||||
}
|
||||
fn codex(dir: &Path) -> Option<String> {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
Some(dir.display().to_string())
|
||||
}
|
||||
fn skill(root: &Path, directory: &str, enabled: bool) -> SkillEntry {
|
||||
let path = root.join("skills").join(directory);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
fs::write(
|
||||
path.join("SKILL.md"),
|
||||
format!("---\nname: {directory}\ndescription: Example\n---\n# Readme"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(path.join(".extra"), b"hidden file").unwrap();
|
||||
fs::create_dir_all(path.join("empty-resources")).unwrap();
|
||||
SkillEntry {
|
||||
directory: directory.into(),
|
||||
path: format!("skills/{directory}"),
|
||||
enabled,
|
||||
note: Some("My skill note".into()),
|
||||
}
|
||||
}
|
||||
fn zip_entries(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in entries {
|
||||
writer
|
||||
.start_file(
|
||||
*name,
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(data).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
fn clean_mcp(id: &str) {
|
||||
let db = open_db().unwrap();
|
||||
db.execute("DELETE FROM managed_mcp_servers WHERE id=?1", [id])
|
||||
.unwrap();
|
||||
db.execute("DELETE FROM skills_mcp_notes WHERE item_id=?1", [id])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundle_round_trip_preserves_files_notes_disabled_skills_and_mcp_settings() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("codex");
|
||||
let directory = format!("archive-roundtrip-{}", std::process::id());
|
||||
let id = format!("archive-roundtrip-mcp-{}", std::process::id());
|
||||
clean_mcp(&id);
|
||||
let disabled = disabled_skills_dir().unwrap().join(&directory);
|
||||
let _ = fs::remove_dir_all(&disabled);
|
||||
let mut bundle = manifest();
|
||||
bundle.skills.push(skill(&source, &directory, false));
|
||||
bundle.mcp_servers.push(McpEntry { id: id.clone(), name: "Portable MCP".into(), config: json!({"command":"example-server", "args":["--safe"], "env":{"TOKEN":"synthetic-test-token"}}), enabled: true, note: Some("MCP note".into()) });
|
||||
let output = temporary.0.join("bundle.zip");
|
||||
write_archive(&output, &bundle, &[source.join("skills").join(&directory)]).unwrap();
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
fs::write(
|
||||
destination.join("config.toml"),
|
||||
"# Keep this\nmodel = \"gpt-test\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let imported =
|
||||
install_skill_archive_path_inner(codex(&destination), output.display().to_string())
|
||||
.unwrap();
|
||||
assert_eq!((imported.imported_skills, imported.imported_mcp), (1, 1));
|
||||
let skill = imported
|
||||
.state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == directory)
|
||||
.unwrap();
|
||||
assert!(!skill.enabled);
|
||||
assert_eq!(skill.note.as_deref(), Some("My skill note"));
|
||||
assert_eq!(fs::read(disabled.join(".extra")).unwrap(), b"hidden file");
|
||||
assert!(disabled.join("empty-resources").is_dir());
|
||||
let mcp = imported
|
||||
.state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|mcp| mcp.id == id)
|
||||
.unwrap();
|
||||
assert_eq!(mcp.note.as_deref(), Some("MCP note"));
|
||||
assert!(mcp.enabled);
|
||||
assert!(fs::read_to_string(destination.join("config.toml"))
|
||||
.unwrap()
|
||||
.starts_with("# Keep this"));
|
||||
let second =
|
||||
install_skill_archive_path_inner(codex(&destination), output.display().to_string())
|
||||
.unwrap();
|
||||
assert_eq!((second.imported_skills, second.imported_mcp), (0, 0));
|
||||
fs::remove_dir_all(disabled).unwrap();
|
||||
clean_mcp(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_skill_zip_larger_than_twenty_megabytes_installs_without_size_limit() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let payload = vec![b'x'; 21 * 1024 * 1024];
|
||||
let bytes = zip_entries(&[
|
||||
(
|
||||
"package/SKILL.md",
|
||||
b"---\nname: large-archive-fixture\n---\n",
|
||||
),
|
||||
("package/data.bin", &payload),
|
||||
]);
|
||||
assert!(bytes.len() > 20 * 1024 * 1024);
|
||||
let result = install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"large.zip".into(),
|
||||
Cursor::new(bytes),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.imported_skills, 1);
|
||||
assert_eq!(
|
||||
fs::metadata(
|
||||
temporary
|
||||
.0
|
||||
.join("codex/skills/large-archive-fixture/data.bin")
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
payload.len() as u64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_bundle_is_rejected_before_any_skill_or_mcp_is_written() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("codex");
|
||||
let mut bundle = manifest();
|
||||
bundle
|
||||
.skills
|
||||
.push(skill(&source, "archive-new-first", true));
|
||||
bundle
|
||||
.skills
|
||||
.push(skill(&source, "archive-existing-second", true));
|
||||
let existing = destination.join("skills/archive-existing-second");
|
||||
fs::create_dir_all(&existing).unwrap();
|
||||
fs::write(existing.join("SKILL.md"), "original").unwrap();
|
||||
assert!(import_manifest(codex(&destination), &source, bundle).is_err());
|
||||
assert!(!destination.join("skills/archive-new-first").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(existing.join("SKILL.md")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_duplicate_and_link_zip_entries_are_rejected() {
|
||||
let temporary = test_dir();
|
||||
for name in [
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"C:/drive",
|
||||
"folder\\escape",
|
||||
"CON.txt",
|
||||
] {
|
||||
let target = TempDir::new(&temporary.0).unwrap();
|
||||
assert!(
|
||||
extract(Cursor::new(zip_entries(&[(name, b"unsafe")])), &target.0).is_err(),
|
||||
"accepted {name}"
|
||||
);
|
||||
}
|
||||
let target = TempDir::new(&temporary.0).unwrap();
|
||||
assert!(extract(
|
||||
Cursor::new(zip_entries(&[("a", b"1"), ("A", b"2")])),
|
||||
&target.0
|
||||
)
|
||||
.is_err());
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.add_symlink("link", "../outside", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
assert!(extract(
|
||||
Cursor::new(writer.finish().unwrap().into_inner()),
|
||||
&TempDir::new(&temporary.0).unwrap().0
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_unknown_version_archives_are_rejected() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
assert!(install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"empty.zip".into(),
|
||||
Cursor::new(zip_entries(&[("readme.txt", b"No skills")]))
|
||||
)
|
||||
.is_err());
|
||||
let mut unknown = manifest();
|
||||
unknown.version = 99;
|
||||
let bytes = serde_json::to_vec(&unknown).unwrap();
|
||||
assert!(install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"future.zip".into(),
|
||||
Cursor::new(zip_entries(&[(MANIFEST, &bytes)]))
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_skill_archive_can_be_imported_with_original_state() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("destination");
|
||||
let directory = format!("archive-export-{}", std::process::id());
|
||||
skill(&source, &directory, true);
|
||||
let archive = temporary.0.join("export.zip");
|
||||
let result = export_skills_mcp_archive_inner(
|
||||
codex(&source),
|
||||
"skills".into(),
|
||||
archive.display().to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.exported_skills >= 1);
|
||||
assert_eq!(result.exported_mcp, 0);
|
||||
let imported =
|
||||
install_skill_archive_path_inner(codex(&destination), archive.display().to_string())
|
||||
.unwrap();
|
||||
assert!(imported
|
||||
.state
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.directory == directory && skill.enabled));
|
||||
assert_eq!(
|
||||
directory_digest(&source.join("skills").join(&directory)).unwrap(),
|
||||
directory_digest(&destination.join("skills").join(&directory)).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_mcp_id_with_different_configuration_does_not_overwrite_live_config() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let destination = temporary.0.join("codex");
|
||||
codex(&destination);
|
||||
let original = "[mcp_servers.archive_conflict]\ncommand = \"existing\"\n";
|
||||
fs::write(destination.join("config.toml"), original).unwrap();
|
||||
let mut bundle = manifest();
|
||||
bundle.mcp_servers.push(McpEntry {
|
||||
id: "archive_conflict".into(),
|
||||
name: "Conflict".into(),
|
||||
config: json!({"command":"different"}),
|
||||
enabled: true,
|
||||
note: None,
|
||||
});
|
||||
assert!(import_manifest(codex(&destination), &temporary.0, bundle).is_err());
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("config.toml")).unwrap(),
|
||||
original
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn export_refuses_changed_destinations_and_cleans_temporary_output() {
|
||||
let temporary = test_dir();
|
||||
let output = temporary.0.join("export.zip");
|
||||
fs::write(&output, b"before").unwrap();
|
||||
let result = write_archive_atomic(&output, &manifest(), &[], || {
|
||||
fs::write(&output, b"external-update").unwrap();
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read(&output).unwrap(), b"external-update");
|
||||
assert_eq!(fs::read_dir(&temporary.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rejects_codex_data_and_non_zip_destinations() {
|
||||
let temporary = test_dir();
|
||||
let codex = temporary.0.join("codex");
|
||||
fs::create_dir_all(&codex).unwrap();
|
||||
assert!(validate_export_destination(&codex.join("backup.zip"), &codex).is_err());
|
||||
assert!(validate_export_destination(&temporary.0.join("auth.json"), &codex).is_err());
|
||||
assert!(validate_export_destination(&temporary.0.join("backup.zip"), &codex).is_ok());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let file = temporary.0.join("original.zip");
|
||||
fs::write(&file, "original").unwrap();
|
||||
let link = temporary.0.join("symlink.zip");
|
||||
std::os::unix::fs::symlink(&file, &link).unwrap();
|
||||
assert!(validate_export_destination(&link, &codex).is_err());
|
||||
assert_eq!(fs::read_to_string(file).unwrap(), "original");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(b) = value.as_bool() {
|
||||
return json!(b);
|
||||
}
|
||||
if let Some(table) = value.as_inline_table() {
|
||||
return Value::Object(
|
||||
table
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_owned(), toml_value_to_json(value)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(arr) = value.as_array() {
|
||||
return Value::Array(arr.iter().map(toml_value_to_json).collect());
|
||||
}
|
||||
@@ -53,7 +61,7 @@ fn toml_item_to_json(item: &Item) -> Value {
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
pub(super) fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
match value_json {
|
||||
Value::String(s) => value(s.clone()),
|
||||
Value::Bool(b) => value(*b),
|
||||
@@ -143,7 +151,7 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
fn save_managed_mcp_on_connection(
|
||||
pub(super) fn save_managed_mcp_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
@@ -198,14 +206,17 @@ fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_table())
|
||||
.filter(|(_, item)| item.as_table_like().is_some())
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
|
||||
pub(super) fn document_bytes(
|
||||
snapshot: Option<&[u8]>,
|
||||
doc: &toml_edit::DocumentMut,
|
||||
) -> Option<Vec<u8>> {
|
||||
let bytes = doc.to_string().into_bytes();
|
||||
if snapshot.is_none() && bytes.is_empty() {
|
||||
None
|
||||
@@ -214,7 +225,7 @@ fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Opti
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
pub(super) fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
transaction: Transaction<'_>,
|
||||
codex_dir: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
@@ -285,16 +296,22 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (id, item) in mcp_tbl.iter() {
|
||||
if !item.is_table() {
|
||||
if item.as_table_like().is_none() {
|
||||
continue;
|
||||
}
|
||||
let config = toml_item_to_json(item);
|
||||
if !is_valid_mcp_config(&config) {
|
||||
continue;
|
||||
}
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
transport,
|
||||
enabled: true,
|
||||
enabled: config
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
source: "config.toml".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
@@ -319,51 +336,7 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
candidates.push((id, name, config, enabled_codex));
|
||||
}
|
||||
let candidates = new_ccswitch_mcp_candidates(codex_dir, imported_ids)?;
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
codex_dir,
|
||||
@@ -388,7 +361,11 @@ where
|
||||
let mut staged_ids = HashSet::new();
|
||||
let candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
|
||||
.filter(|(id, _, config, _)| {
|
||||
is_valid_mcp_config(config)
|
||||
&& !imported_ids.contains(id)
|
||||
&& staged_ids.insert(id.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
@@ -429,9 +406,83 @@ where
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
) -> Result<Vec<ManagedMcpServer>> {
|
||||
// Both preview and import use the same validated records. Malformed JSON is never
|
||||
// converted to an empty stdio server.
|
||||
pub(super) fn is_valid_mcp_config(config: &Value) -> bool {
|
||||
let Some(object) = config.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let nonempty = |key| {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
};
|
||||
let command = nonempty("command");
|
||||
let url = nonempty("url");
|
||||
if command == url {
|
||||
return false;
|
||||
}
|
||||
if command
|
||||
&& object.get("args").is_some_and(|args| {
|
||||
!args
|
||||
.as_array()
|
||||
.is_some_and(|values| values.iter().all(Value::is_string))
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if object.get("env").is_some_and(|env| {
|
||||
!env.as_object()
|
||||
.is_some_and(|values| values.values().all(Value::is_string))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn mcp_configs_equal(left: &Value, right: &Value) -> bool {
|
||||
let normalize = |value: &Value| {
|
||||
let mut config = value.clone();
|
||||
if let Some(object) = config.as_object_mut() {
|
||||
// Transport is inferred from command/url by Codex; enabled is stored
|
||||
// separately from the connection settings in managed records.
|
||||
object.remove("type");
|
||||
object.remove("enabled");
|
||||
for key in ["args", "env", "http_headers", "env_http_headers"] {
|
||||
if object.get(key).is_some_and(|value| {
|
||||
value.as_array().is_some_and(Vec::is_empty)
|
||||
|| value.as_object().is_some_and(serde_json::Map::is_empty)
|
||||
}) {
|
||||
object.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
config
|
||||
};
|
||||
normalize(left) == normalize(right)
|
||||
}
|
||||
|
||||
fn normalized_ccswitch_config(config: Value, id: &str) -> Option<Value> {
|
||||
if is_valid_mcp_config(&config) {
|
||||
return Some(config);
|
||||
}
|
||||
for key in ["mcpServers", "mcp_servers"] {
|
||||
if let Some(servers) = config.get(key).and_then(Value::as_object) {
|
||||
let server = servers.get(id).or_else(|| {
|
||||
(servers.len() == 1)
|
||||
.then(|| servers.values().next())
|
||||
.flatten()
|
||||
})?;
|
||||
if is_valid_mcp_config(server) {
|
||||
return Some(server.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_ccswitch_mcp_candidates() -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(vec![]);
|
||||
@@ -440,29 +491,20 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
read_ccswitch_mcp_candidates_from_connection(&conn)
|
||||
}
|
||||
|
||||
fn read_ccswitch_mcp_candidates_from_connection(
|
||||
conn: &Connection,
|
||||
) -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
.or_else(|_| conn.prepare("SELECT id, name, server_config, 0 FROM mcp_servers ORDER BY name ASC, id ASC")) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message))) if message.to_lowercase().contains("no such table") => return Ok(vec![]),
|
||||
Err(error) => return Err(CodexxError::Database(error.to_string())),
|
||||
};
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
@@ -472,30 +514,90 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.clone(),
|
||||
name,
|
||||
transport,
|
||||
enabled: enabled_codex || live_enabled.contains(&id),
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
let (id, name, text, enabled) =
|
||||
row.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(config) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.and_then(|config| normalized_ccswitch_config(config, &id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = if name.trim().is_empty() {
|
||||
id.clone()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
out.push((id, name, config, enabled));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn new_ccswitch_mcp_candidates(
|
||||
codex_dir: &Path,
|
||||
already_seen: &HashSet<String>,
|
||||
) -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let current = list_mcp_from_config(codex_dir)?;
|
||||
let managed = db_managed_mcp()?;
|
||||
let mut ids = already_seen.clone();
|
||||
let mut configs = Vec::new();
|
||||
for server in current {
|
||||
ids.insert(server.id);
|
||||
configs.push(server.config_json);
|
||||
}
|
||||
for (id, _, config, _) in managed {
|
||||
ids.insert(id);
|
||||
if is_valid_mcp_config(&config) {
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for candidate in read_ccswitch_mcp_candidates()? {
|
||||
if !ids.insert(candidate.0.clone())
|
||||
|| configs
|
||||
.iter()
|
||||
.any(|config| mcp_configs_equal(config, &candidate.2))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
configs.push(candidate.2.clone());
|
||||
output.push(candidate);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
) -> Result<Vec<ManagedMcpServer>> {
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(new_ccswitch_mcp_candidates(codex_dir, &HashSet::new())?
|
||||
.into_iter()
|
||||
.map(|(id, name, config, enabled)| {
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
ManagedMcpServer {
|
||||
enabled: enabled || live_enabled.contains(&id),
|
||||
id,
|
||||
name,
|
||||
transport,
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_codex_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
@@ -529,8 +631,19 @@ where
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let stored = managed_mcp_on_connection(&transaction, &id)?;
|
||||
if enabled {
|
||||
let (name, config, _) =
|
||||
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
let (name, mut config, _) = stored
|
||||
.or_else(|| {
|
||||
doc.get("mcp_servers")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|table| table.get(&id))
|
||||
.map(|item| (id.clone(), toml_item_to_json(item), false))
|
||||
})
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
if let Some(object) = config.as_object_mut() {
|
||||
if object.contains_key("enabled") {
|
||||
object.insert("enabled".to_owned(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
|
||||
} else {
|
||||
@@ -596,6 +709,75 @@ mod tests {
|
||||
.expect("remove test MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_existing_unmanaged_disabled_mcp_updates_enabled_flag() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let (directory, id) = test_case("disabled-unmanaged");
|
||||
let config = format!("[mcp_servers.\"{id}\"]\ncommand = \"fixture\"\nenabled = false\n");
|
||||
std::fs::write(directory.join("config.toml"), config).unwrap();
|
||||
let initial = list_mcp_from_config(&directory).unwrap();
|
||||
assert!(!initial[0].enabled);
|
||||
let result =
|
||||
toggle_codex_mcp_inner(Some(directory.display().to_string()), id.clone(), true)
|
||||
.unwrap();
|
||||
assert!(
|
||||
result
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
remove_test_mcp(&id);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_candidates_skip_empty_invalid_and_transport_only_records() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE mcp_servers(id TEXT,name TEXT,server_config TEXT,enabled_codex BOOLEAN)",
|
||||
)
|
||||
.unwrap();
|
||||
for (id, data) in [
|
||||
("broken", "invalid"),
|
||||
("empty", "{}"),
|
||||
("transport", r#"{"type":"stdio"}"#),
|
||||
("valid", r#"{"command":"npx","args":["server"]}"#),
|
||||
(
|
||||
"wrapped",
|
||||
r#"{"mcpServers":{"wrapped":{"url":"https://example.test/mcp"}}}"#,
|
||||
),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO mcp_servers VALUES(?1,?1,?2,0)",
|
||||
params![id, data],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let candidates = read_ccswitch_mcp_candidates_from_connection(&conn).unwrap();
|
||||
assert_eq!(
|
||||
candidates
|
||||
.iter()
|
||||
.map(|entry| entry.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["valid", "wrapped"]
|
||||
);
|
||||
assert_eq!(candidates[1].2["url"], "https://example.test/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_environment_round_trip_retains_mcp_credentials() {
|
||||
let document: toml_edit::DocumentMut =
|
||||
"[mcp_servers.example]\ncommand = \"example\"\nenv = { TOKEN = \"synthetic\" }\n"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let value = toml_item_to_json(&document["mcp_servers"]["example"]);
|
||||
assert!(is_valid_mcp_config(&value));
|
||||
assert_eq!(value["env"]["TOKEN"], "synthetic");
|
||||
assert_eq!(toml_item_to_json(&json_to_toml_item(&value)), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod archive;
|
||||
mod mcp;
|
||||
pub(crate) use archive::{export_skills_mcp_archive_inner, install_skill_archive_path_inner};
|
||||
mod skills;
|
||||
mod types;
|
||||
|
||||
@@ -8,7 +10,8 @@ pub(crate) use skills::{
|
||||
sort_managed_skills, toggle_codex_skill_inner,
|
||||
};
|
||||
pub(crate) use types::{
|
||||
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
|
||||
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpExportResult, SkillsMcpImportPreview,
|
||||
SkillsMcpState,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -17,7 +20,7 @@ pub(crate) use skills::read_skill_metadata;
|
||||
pub(crate) use types::ManagedSkill;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::file_io::ensure_directory;
|
||||
use crate::paths::{home_dir, normalized_path_scope};
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::{now_rfc3339, open_db};
|
||||
@@ -26,10 +29,9 @@ use mcp::{
|
||||
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
|
||||
};
|
||||
use skills::{codex_skills_dir, copy_dir_recursive, disabled_skills_dir, scan_skill_dir};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
#[cfg(test)]
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -128,7 +130,7 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
let mut mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
let enabled_ids: HashSet<String> = mcp_servers.iter().map(|s| s.id.clone()).collect();
|
||||
for (id, name, config, enabled) in db_managed_mcp()? {
|
||||
if enabled_ids.contains(&id) {
|
||||
if enabled_ids.contains(&id) || !mcp::is_valid_mcp_config(&config) {
|
||||
continue;
|
||||
}
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
@@ -221,31 +223,16 @@ pub(crate) fn import_existing_skills_mcp_inner(
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let preview = preview_existing_skills_mcp_inner(config_dir.clone())?;
|
||||
let mut imported_skills = 0usize;
|
||||
let candidates = vec![
|
||||
home_dir()?.join(".agents").join("skills"),
|
||||
home_dir()?.join(".cc-switch").join("skills"),
|
||||
];
|
||||
for base in candidates {
|
||||
if !base.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(&base).map_err(|e| io_err(&base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&base, e))?;
|
||||
let src = entry.path();
|
||||
if !src.is_dir() || !src.join("SKILL.md").is_file() {
|
||||
continue;
|
||||
}
|
||||
let directory = sanitize_dir_name(&entry.file_name().to_string_lossy(), "skill");
|
||||
let dst = skills_dir.join(&directory);
|
||||
if !dst.exists() {
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
for skill in preview.skills {
|
||||
let destination = skills_dir.join(&skill.directory);
|
||||
if !destination.exists() {
|
||||
copy_dir_recursive(Path::new(&skill.path), &destination)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported_mcp = 0usize;
|
||||
let mut imported_mcp_ids = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.map(|(id, _, _, _)| id)
|
||||
@@ -254,10 +241,11 @@ pub(crate) fn import_existing_skills_mcp_inner(
|
||||
if !imported_mcp_ids.insert(server.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
// Already visible in the list: record it for deduplication without
|
||||
// presenting it as a newly imported server.
|
||||
save_managed_mcp(&server.id, &server.name, &server.config_json, true)?;
|
||||
imported_mcp += 1;
|
||||
}
|
||||
imported_mcp += import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
|
||||
let imported_mcp = import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
@@ -272,6 +260,7 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
) -> Result<SkillsMcpImportPreview> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let disabled_dir = disabled_skills_dir()?;
|
||||
let mut warnings = Vec::new();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -293,14 +282,20 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
warnings.push(e.to_string());
|
||||
}
|
||||
for skill in &mut skills[before..] {
|
||||
if skills_dir.join(&skill.directory).exists() {
|
||||
if skills_dir.join(&skill.directory).exists()
|
||||
|| disabled_dir.join(&skill.directory).exists()
|
||||
{
|
||||
skill.update_status = "已存在,将跳过".to_string();
|
||||
} else {
|
||||
skill.update_status = "可导入".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
skills.retain(|skill| skill.update_status != "已存在,将跳过");
|
||||
let mut candidate_ids = HashSet::new();
|
||||
skills.retain(|skill| {
|
||||
skill.update_status != "已存在,将跳过"
|
||||
&& candidate_ids.insert(skill.directory.to_ascii_lowercase())
|
||||
});
|
||||
|
||||
let mut config_mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
for server in &mut config_mcp_servers {
|
||||
@@ -311,7 +306,7 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
.map(|(id, _, _, _)| id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut mcp_servers = Vec::new();
|
||||
extend_unmanaged_mcp_candidates(&mut mcp_servers, &mut seen_mcp, config_mcp_servers);
|
||||
seen_mcp.extend(config_mcp_servers.into_iter().map(|server| server.id));
|
||||
extend_unmanaged_mcp_candidates(
|
||||
&mut mcp_servers,
|
||||
&mut seen_mcp,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{CcSwitchSkillMeta, ManagedSkill, SkillsMcpActionResult, SkillsMcpState};
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::constants::MAX_SKILL_ZIP_BYTES;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, read_to_string_if_exists};
|
||||
use crate::paths::app_home;
|
||||
use crate::{now_rfc3339, open_db, resolve_codex_dir};
|
||||
use chrono::Local;
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
@@ -101,8 +99,15 @@ fn compute_dir_hash(dir: &Path) -> Result<String> {
|
||||
.replace('\\', "/");
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
let bytes = fs::read(&path).map_err(|e| io_err(&path, e))?;
|
||||
hasher.update(&bytes);
|
||||
let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let size = file.read(&mut buffer).map_err(|e| io_err(&path, e))?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..size]);
|
||||
}
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
@@ -338,83 +343,7 @@ pub(crate) fn install_skill_zip_inner(
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 失败: {e}")))?;
|
||||
let tmp = app_home()?
|
||||
.join("tmp")
|
||||
.join(format!("skill-zip-{}", Local::now().timestamp_millis()));
|
||||
ensure_directory(&tmp)?;
|
||||
let install_result = (|| -> Result<usize> {
|
||||
let mut total_size = 0u64;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 条目失败: {e}")))?;
|
||||
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
total_size += file.size();
|
||||
if total_size > MAX_SKILL_ZIP_BYTES {
|
||||
return Err(CodexxError::Config("ZIP 解压后超过 20MB".to_string()));
|
||||
}
|
||||
let out = tmp.join(path);
|
||||
if file.name().ends_with('/') {
|
||||
ensure_directory(&out)?;
|
||||
} else {
|
||||
if let Some(parent) = out.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&out).map_err(|e| io_err(&out, e))?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| io_err(&out, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut skill_dirs = Vec::new();
|
||||
fn find_skill_dirs(current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if current.join("SKILL.md").is_file() {
|
||||
out.push(current.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
|
||||
let entry = entry.map_err(|e| io_err(current, e))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
find_skill_dirs(&path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
find_skill_dirs(&tmp, &mut skill_dirs)?;
|
||||
if skill_dirs.is_empty() {
|
||||
return Err(CodexxError::Config("ZIP 中没有找到 SKILL.md".to_string()));
|
||||
}
|
||||
let mut imported_skills = 0usize;
|
||||
for src in skill_dirs {
|
||||
let fallback = file_name.trim_end_matches(".zip");
|
||||
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
|
||||
let (skill_name, _) = read_skill_metadata(&src, dir_name);
|
||||
let dst_name = sanitize_dir_name(&skill_name, "skill");
|
||||
let dst = skills_dir.join(dst_name);
|
||||
if dst.exists() {
|
||||
fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?;
|
||||
}
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
Ok(imported_skills)
|
||||
})();
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
let imported_skills = install_result?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp: 0,
|
||||
message: format!("已从 ZIP 安装 {imported_skills} 个 Skill"),
|
||||
state,
|
||||
})
|
||||
super::archive::install_archive_reader(config_dir, file_name, Cursor::new(bytes))
|
||||
}
|
||||
|
||||
fn ccswitch_skill_meta_by_directory() -> Result<HashMap<String, CcSwitchSkillMeta>> {
|
||||
|
||||
@@ -66,3 +66,11 @@ pub(super) struct CcSwitchSkillMeta {
|
||||
pub(super) repo_branch: String,
|
||||
pub(super) content_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillsMcpExportResult {
|
||||
pub(crate) path: String,
|
||||
pub(crate) exported_skills: usize,
|
||||
pub(crate) exported_mcp: usize,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::prompts::{
|
||||
};
|
||||
use crate::providers::{
|
||||
clear_active_provider_on_connection, detected_live_custom_provider, document_is_official,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live_on_connection,
|
||||
official_auth_available, open_store, reconcile_active_provider_on_connection, SavedProvider,
|
||||
};
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
@@ -201,6 +201,7 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
let auth = auth_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let doc = crate::failover::direct_document(&codex_dir, &doc)?;
|
||||
let model = string_value(&doc, "model");
|
||||
let model_provider = string_value(&doc, "model_provider");
|
||||
let is_official_provider = document_is_official(&doc);
|
||||
@@ -227,10 +228,16 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
clear_active_provider_on_connection(&conn, &codex_dir)?;
|
||||
None
|
||||
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
|
||||
let candidates = matching_saved_provider_ids_for_live(&live, &saved_providers);
|
||||
let candidates = matching_saved_provider_ids_for_live_on_connection(
|
||||
&conn,
|
||||
&codex_dir,
|
||||
&live,
|
||||
&saved_providers,
|
||||
)?;
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
} else {
|
||||
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
|
||||
let candidates =
|
||||
matching_saved_provider_ids_from_config(&doc.to_string(), &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
};
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ fn provider_test_connection() -> Connection {
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -160,6 +161,7 @@ fn provider_fixture(
|
||||
toml_config: toml_config.map(ToString::to_string),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -960,6 +962,7 @@ wire_api = "responses"
|
||||
toml_config: Some(toml.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
};
|
||||
let live = second_toml.replace(
|
||||
"wire_api = \"responses\"",
|
||||
@@ -1807,6 +1810,7 @@ enabled = true
|
||||
toml_config: Some(provider_a_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
},
|
||||
SavedProvider {
|
||||
id: provider_b_id.clone(),
|
||||
@@ -1817,6 +1821,7 @@ enabled = true
|
||||
toml_config: Some(provider_b_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
},
|
||||
] {
|
||||
save_provider_inner(provider).expect("save provider record");
|
||||
@@ -2093,9 +2098,10 @@ command = "docs-server"
|
||||
.config_text
|
||||
.parse::<DocumentMut>()
|
||||
.expect("parse provider B live config");
|
||||
assert!(live_doc["model_providers"]["custom"]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
live_doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
|
||||
Some("sk-b")
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
@@ -2725,6 +2731,7 @@ requires_openai_auth = true
|
||||
toml_config: Some(provider_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
})
|
||||
.expect("save provider without stored key");
|
||||
write_text(&config_path(&codex_dir), provider_config).expect("write live provider config");
|
||||
@@ -3192,6 +3199,7 @@ requires_openai_auth = true
|
||||
toml_config: Some(custom_config.trim_end().to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
};
|
||||
save_provider_inner(original.clone()).expect("save original provider");
|
||||
assert_eq!(
|
||||
@@ -3755,9 +3763,10 @@ requires_openai_auth = false
|
||||
let config_doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.expect("parse provider config");
|
||||
assert!(config_doc["model_providers"]["custom"]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
config_doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
|
||||
Some("sk-provider-table")
|
||||
);
|
||||
let auth_after: Value = serde_json::from_str(
|
||||
&fs::read_to_string(auth_path(&codex_dir)).expect("read auth after save"),
|
||||
)
|
||||
@@ -3913,9 +3922,10 @@ command = "must-not-replace-live"
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://new.example.com/v1")
|
||||
);
|
||||
assert!(doc["model_providers"]["custom"]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
|
||||
Some("sk-new")
|
||||
);
|
||||
let auth: Value = serde_json::from_str(
|
||||
&fs::read_to_string(auth_path(&codex_dir)).expect("read merged provider auth"),
|
||||
)
|
||||
@@ -4017,9 +4027,10 @@ requires_openai_auth = false
|
||||
.config_text
|
||||
.parse::<DocumentMut>()
|
||||
.expect("parse provider B live config");
|
||||
assert!(live_doc["model_providers"]["custom"]
|
||||
.get("experimental_bearer_token")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
live_doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
|
||||
Some("sk-b")
|
||||
);
|
||||
assert!(!result
|
||||
.state
|
||||
.config_text
|
||||
@@ -4560,7 +4571,8 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
let root_b = "019f6000-0000-7000-8000-000000000002";
|
||||
let child = "019f6000-0000-7000-8000-000000000003";
|
||||
let orphan_subagent = "019f6000-0000-7000-8000-000000000004";
|
||||
let forked_user = "019f6000-0000-7000-8000-000000000005";
|
||||
let mislabeled_child = "019f6000-0000-7000-8000-000000000005";
|
||||
let forked_user = "019f6000-0000-7000-8000-000000000006";
|
||||
let rollout = codex_dir.join("sessions/rollout.jsonl");
|
||||
seed_thread_database(
|
||||
&database,
|
||||
@@ -4568,6 +4580,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
(root_a, &rollout),
|
||||
(root_b, &rollout),
|
||||
(child, &rollout),
|
||||
(mislabeled_child, &rollout),
|
||||
(forked_user, &rollout),
|
||||
],
|
||||
Some((root_a, child)),
|
||||
@@ -4577,6 +4590,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
"ALTER TABLE threads ADD COLUMN title TEXT;
|
||||
ALTER TABLE threads ADD COLUMN source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN thread_source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN forked_from_id TEXT;
|
||||
UPDATE threads SET title = 'same title';",
|
||||
)
|
||||
.expect("extend thread schema");
|
||||
@@ -4587,14 +4601,19 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
.expect("mark child subagent");
|
||||
conn.execute(
|
||||
"UPDATE threads SET thread_source = 'user' WHERE id = ?1",
|
||||
[forked_user],
|
||||
[mislabeled_child],
|
||||
)
|
||||
.expect("mark forked user thread");
|
||||
.expect("seed generic user label on an actual spawned child");
|
||||
conn.execute(
|
||||
"INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id) VALUES (?1, ?2)",
|
||||
(root_a, mislabeled_child),
|
||||
)
|
||||
.expect("insert actual child spawn edge");
|
||||
conn.execute(
|
||||
"UPDATE threads SET thread_source = 'user', source = 'cli', forked_from_id = ?1 WHERE id = ?2",
|
||||
(root_a, forked_user),
|
||||
)
|
||||
.expect("insert user fork edge");
|
||||
.expect("mark a real user fork without a spawn edge");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path, title, source)
|
||||
VALUES (?1, 'openai', ?2, 'same title', ?3)",
|
||||
@@ -4609,14 +4628,25 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
|
||||
let rollouts = scan_rollouts(&codex_dir, "openai").expect("scan rollouts");
|
||||
let scan = scan_sqlite(&codex_dir, &rollouts, "openai").expect("scan sqlite");
|
||||
assert_eq!(scan.sqlite_threads, 5);
|
||||
assert_eq!(scan.sqlite_threads, 6);
|
||||
assert_eq!(scan.top_level_threads, 3);
|
||||
assert_eq!(scan.subagent_threads, 2);
|
||||
assert_eq!(scan.subagent_threads, 3);
|
||||
assert!(scan.syncable_thread_ids.contains(forked_user));
|
||||
assert!(!scan.syncable_thread_ids.contains(mislabeled_child));
|
||||
|
||||
let (previews, warnings) =
|
||||
list_session_previews(&codex_dir, &rollouts, "openai", 50).expect("list previews");
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 2);
|
||||
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 3);
|
||||
for preview in &previews {
|
||||
assert_eq!(
|
||||
preview.is_subagent,
|
||||
[child, orphan_subagent, mislabeled_child].contains(&preview.id.as_str())
|
||||
);
|
||||
if preview.is_subagent {
|
||||
assert!(!preview.needs_sync);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
previews
|
||||
.into_iter()
|
||||
@@ -4627,6 +4657,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
root_b.to_string(),
|
||||
child.to_string(),
|
||||
orphan_subagent.to_string(),
|
||||
mislabeled_child.to_string(),
|
||||
forked_user.to_string(),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Native file selection keeps large archives out of the renderer/IPC payload.
|
||||
use crate::error::{CodexxError, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn export_name(suggested: &str, fallback: &str, extension: &str) -> String {
|
||||
let mut bytes = 0;
|
||||
let stem: String = suggested
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !"/\\:*?\"<>|".contains(*c))
|
||||
.take_while(|c| {
|
||||
bytes += c.len_utf8();
|
||||
bytes <= 180
|
||||
})
|
||||
.collect();
|
||||
let stem = stem.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
let stem = if stem.is_empty() { fallback } else { stem };
|
||||
let prefix = stem
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
let reserved = matches!(prefix.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (prefix.len() == 4
|
||||
&& (prefix.starts_with("COM") || prefix.starts_with("LPT"))
|
||||
&& prefix.as_bytes()[3].is_ascii_digit());
|
||||
format!("{}{stem}.{extension}", if reserved { "_" } else { "" })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn suggested_names_fit_filesystems_without_losing_utf8_or_using_windows_devices() {
|
||||
for title in ["中".repeat(100), "🌏".repeat(100)] {
|
||||
let name = export_name(&title, "session", "md");
|
||||
assert!(name.len() <= 184);
|
||||
assert!(name.ends_with(".md"));
|
||||
assert!(!name.contains('\u{fffd}'));
|
||||
}
|
||||
for title in ["CON", "nul", "PRN.notes", "LPT1", "COM9"] {
|
||||
assert!(export_name(title, "session", "md").starts_with('_'));
|
||||
}
|
||||
assert_eq!(export_name(" . /\\ ", "session", "zip"), "session.zip");
|
||||
}
|
||||
}
|
||||
|
||||
fn require_extension(path: &Path, extension: &str) -> Result<()> {
|
||||
if !path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"请使用 .{extension} 文件名保存。"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_path(
|
||||
window: &tauri::WebviewWindow,
|
||||
name: &str,
|
||||
extension: &str,
|
||||
title: &str,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(window)
|
||||
.set_title(title)
|
||||
.set_file_name(name)
|
||||
.add_filter(extension, &[extension])
|
||||
.save_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
require_extension(file.path(), extension)?;
|
||||
Ok(Some(file.path().to_path_buf()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn export_codex_sessions(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
session_ids: Vec<String>,
|
||||
suggested_name: String,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::sessions::SessionExportResult>> {
|
||||
if session_ids.is_empty() {
|
||||
return Err(CodexxError::Config("请先选择要导出的会话。".into()));
|
||||
}
|
||||
let extension = if session_ids.len() == 1 { "md" } else { "zip" };
|
||||
let name = export_name(&suggested_name, "Codex-sessions", extension);
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
"Export conversations"
|
||||
} else {
|
||||
"导出会话"
|
||||
};
|
||||
let Some(destination) = save_path(&window, &name, extension, title).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::sessions::export_codex_sessions_inner(
|
||||
config_dir,
|
||||
session_ids,
|
||||
destination.to_string_lossy().into_owned(),
|
||||
)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导出会话失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn export_skills_mcp_archive(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
kind: String,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::skills_mcp::SkillsMcpExportResult>> {
|
||||
let label = match kind.as_str() {
|
||||
"mcp" => "MCP",
|
||||
"skills" => "Skills",
|
||||
_ => return Err(CodexxError::Config("不支持的导出类型。".into())),
|
||||
};
|
||||
let name = format!(
|
||||
"Codex-X-{label}-{}.zip",
|
||||
chrono::Local::now().format("%Y%m%d")
|
||||
);
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
format!("Export {label}")
|
||||
} else {
|
||||
format!("导出 {label}")
|
||||
};
|
||||
let Some(destination) = save_path(&window, &name, "zip", &title).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::skills_mcp::export_skills_mcp_archive_inner(
|
||||
config_dir,
|
||||
kind,
|
||||
destination.to_string_lossy().into_owned(),
|
||||
)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导出 ZIP 失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_skills_mcp_archive(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::skills_mcp::SkillsMcpActionResult>> {
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
"Import Skills / MCP ZIP"
|
||||
} else {
|
||||
"导入 Skills / MCP ZIP"
|
||||
};
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(&window)
|
||||
.set_title(title)
|
||||
.add_filter("ZIP", &["zip"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = file.path().to_string_lossy().into_owned();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::skills_mcp::install_skill_archive_path_inner(config_dir, path).map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导入 ZIP 失败,请重试。".into()))?
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use chrono::{DateTime, Duration, Local, NaiveDate, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fs::{self, File, Metadata};
|
||||
@@ -13,7 +14,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const MAX_LINE_BYTES: usize = 2 * 1024 * 1024;
|
||||
// Keep ordinary rollout records on the fast slice parser. Larger records use
|
||||
// the streaming parser, which skips image/tool/message bodies without retaining
|
||||
// their contents. This is a buffer threshold, never a record-size limit.
|
||||
const MAX_BUFFERED_LINE_BYTES: usize = 64 * 1024;
|
||||
const MAX_SCAN_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
const MAX_FILES: usize = 25_000;
|
||||
const MAX_DEPTH: usize = 6;
|
||||
@@ -185,6 +189,97 @@ struct RateLimits {
|
||||
limit_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Keep only the source classification and parent ID used for grouping agents.
|
||||
/// In particular, newer source metadata may embed large, unrelated payloads.
|
||||
#[derive(Debug, Default)]
|
||||
struct UsageSource {
|
||||
is_subagent: bool,
|
||||
parent: Option<String>,
|
||||
}
|
||||
|
||||
struct UsageSourceSeed(usize);
|
||||
|
||||
impl<'de> DeserializeSeed<'de> for UsageSourceSeed {
|
||||
type Value = UsageSource;
|
||||
|
||||
fn deserialize<D: Deserializer<'de>>(
|
||||
self,
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self::Value, D::Error> {
|
||||
deserializer.deserialize_any(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Visitor<'de> for UsageSourceSeed {
|
||||
type Value = UsageSource;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("session source metadata")
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource {
|
||||
is_subagent: self.0 == 0 && crate::sessions::source_kind_is_internal(value),
|
||||
parent: (self.0 == 3).then(|| value.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(
|
||||
self,
|
||||
mut map: M,
|
||||
) -> std::result::Result<Self::Value, M::Error> {
|
||||
const PATH: [&str; 3] = ["subagent", "thread_spawn", "parent_thread_id"];
|
||||
let mut source = UsageSource::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if self.0 == 0 && key == "internal" {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
source.is_subagent = true;
|
||||
} else if PATH.get(self.0).copied() == Some(key.as_str()) {
|
||||
let child = map.next_value_seed(UsageSourceSeed(self.0 + 1))?;
|
||||
source.is_subagent |= self.0 == 0;
|
||||
source.parent = child.parent;
|
||||
} else {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn visit_seq<A: SeqAccess<'de>>(
|
||||
self,
|
||||
mut sequence: A,
|
||||
) -> std::result::Result<Self::Value, A::Error> {
|
||||
while sequence.next_element::<IgnoredAny>()?.is_some() {}
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UsageSource {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
UsageSourceSeed(0).deserialize(deserializer)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct LogPayload {
|
||||
#[serde(rename = "type", default)]
|
||||
@@ -193,7 +288,8 @@ struct LogPayload {
|
||||
#[serde(alias = "threadId")]
|
||||
thread_id: Option<String>,
|
||||
forked_from_id: Option<String>,
|
||||
source: Option<serde_json::Value>,
|
||||
source: Option<UsageSource>,
|
||||
thread_source: Option<String>,
|
||||
cwd: Option<String>,
|
||||
model: Option<String>,
|
||||
info: Option<TokenInfo>,
|
||||
@@ -226,7 +322,6 @@ struct TokenEvent {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ParseIssues {
|
||||
malformed_lines: usize,
|
||||
oversized_lines: usize,
|
||||
invalid_usage: usize,
|
||||
incomplete_breakdown: usize,
|
||||
invalid_timestamps: usize,
|
||||
@@ -235,7 +330,6 @@ struct ParseIssues {
|
||||
impl ParseIssues {
|
||||
fn add(&mut self, other: &Self) {
|
||||
self.malformed_lines += other.malformed_lines;
|
||||
self.oversized_lines += other.oversized_lines;
|
||||
self.invalid_usage += other.invalid_usage;
|
||||
self.incomplete_breakdown += other.incomplete_breakdown;
|
||||
self.invalid_timestamps += other.invalid_timestamps;
|
||||
@@ -296,13 +390,7 @@ fn timestamp(value: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
}
|
||||
|
||||
impl ParsedLog {
|
||||
fn parse_line(&mut self, bytes: &[u8]) -> std::result::Result<(), serde_json::Error> {
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Ok(());
|
||||
}
|
||||
// Serde skips unknown fields. In particular, it does not materialize a
|
||||
// response_item's message bodies or a turn_context's instructions.
|
||||
let record: LogRecord = serde_json::from_slice(bytes)?;
|
||||
fn parse_record(&mut self, record: LogRecord) {
|
||||
let time = timestamp(record.timestamp.as_deref());
|
||||
if let Some(time) = time {
|
||||
self.max_timestamp = Some(
|
||||
@@ -323,15 +411,17 @@ impl ParsedLog {
|
||||
let spawned = payload
|
||||
.source
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/subagent/thread_spawn/parent_thread_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned);
|
||||
.and_then(|value| value.parent.clone());
|
||||
let forked = nonempty(payload.forked_from_id);
|
||||
let spawned = nonempty(spawned);
|
||||
self.is_subagent = payload
|
||||
.source
|
||||
.as_ref()
|
||||
.is_some_and(crate::sessions::source_value_is_subagent);
|
||||
.is_some_and(|source| source.is_subagent)
|
||||
|| payload
|
||||
.thread_source
|
||||
.as_deref()
|
||||
.is_some_and(crate::sessions::thread_source_is_internal);
|
||||
self.replays_parent = forked.is_some();
|
||||
self.spawn_parent = spawned.clone();
|
||||
self.invalid_spawn_parent = self.id.is_some() && self.id == self.spawn_parent;
|
||||
@@ -360,7 +450,7 @@ impl ParsedLog {
|
||||
}
|
||||
"event_msg" if payload.kind == "token_count" => {
|
||||
let Some(info) = payload.info else {
|
||||
return Ok(());
|
||||
return;
|
||||
};
|
||||
if let Some(model) = nonempty(info.model.or(info.model_name).or(payload.model)) {
|
||||
self.model = model;
|
||||
@@ -375,7 +465,7 @@ impl ParsedLog {
|
||||
.and_then(RawCounters::counters);
|
||||
if total.is_none() && last.is_none() {
|
||||
self.issues.invalid_usage += 1;
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
let effective = if last.is_some() {
|
||||
info.last_token_usage.as_ref()
|
||||
@@ -399,7 +489,6 @@ impl ParsedLog {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,41 +573,123 @@ fn anchors(file: &mut File, offset: u64) -> std::io::Result<([u8; 32], [u8; 32])
|
||||
Ok((prefix, boundary))
|
||||
}
|
||||
|
||||
struct BoundedLine {
|
||||
bytes: Vec<u8>,
|
||||
struct ParsedLine {
|
||||
record: std::result::Result<Option<LogRecord>, serde_json::Error>,
|
||||
consumed: u64,
|
||||
terminated: bool,
|
||||
oversized: bool,
|
||||
}
|
||||
|
||||
fn read_bounded_line(reader: &mut impl BufRead) -> std::io::Result<BoundedLine> {
|
||||
let mut result = BoundedLine {
|
||||
bytes: Vec::new(),
|
||||
consumed: 0,
|
||||
terminated: false,
|
||||
oversized: false,
|
||||
};
|
||||
/// Presents exactly one physical JSONL line to Serde. Caching the next newline
|
||||
/// boundary avoids rescanning the entire buffer for each byte Serde requests.
|
||||
/// Syntax errors can be drained to the same boundary, preserving the next event.
|
||||
struct JsonLineReader<'a, R> {
|
||||
reader: &'a mut R,
|
||||
available: usize,
|
||||
ends_line: bool,
|
||||
consumed: u64,
|
||||
terminated: bool,
|
||||
non_whitespace: bool,
|
||||
}
|
||||
|
||||
impl<'a, R: BufRead> JsonLineReader<'a, R> {
|
||||
fn new(reader: &'a mut R) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
available: 0,
|
||||
ends_line: false,
|
||||
consumed: 0,
|
||||
terminated: false,
|
||||
non_whitespace: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&mut self) -> std::io::Result<()> {
|
||||
std::io::copy(self, &mut std::io::sink())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: BufRead> Read for JsonLineReader<'_, R> {
|
||||
fn read(&mut self, output: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.terminated || output.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let buffer = self.reader.fill_buf()?;
|
||||
if self.available == 0 {
|
||||
let newline = buffer.iter().position(|byte| *byte == b'\n');
|
||||
self.available = newline.map_or(buffer.len(), |index| index + 1);
|
||||
self.ends_line = newline.is_some();
|
||||
}
|
||||
let count = output.len().min(self.available);
|
||||
output[..count].copy_from_slice(&buffer[..count]);
|
||||
self.non_whitespace = self.non_whitespace
|
||||
|| output[..count]
|
||||
.iter()
|
||||
.any(|byte| !byte.is_ascii_whitespace());
|
||||
self.reader.consume(count);
|
||||
self.consumed += count as u64;
|
||||
self.available -= count;
|
||||
self.terminated = self.available == 0 && self.ends_line;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_record_slice(bytes: &[u8]) -> std::result::Result<Option<LogRecord>, serde_json::Error> {
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
Ok(None)
|
||||
} else {
|
||||
serde_json::from_slice(bytes).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_log_record(reader: &mut impl BufRead) -> std::io::Result<ParsedLine> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let buffer = reader.fill_buf()?;
|
||||
if buffer.is_empty() {
|
||||
break;
|
||||
return Ok(ParsedLine {
|
||||
record: parse_record_slice(&bytes),
|
||||
consumed: bytes.len() as u64,
|
||||
terminated: false,
|
||||
});
|
||||
}
|
||||
let newline = buffer.iter().position(|byte| *byte == b'\n');
|
||||
let count = newline.map_or(buffer.len(), |index| index + 1);
|
||||
result.consumed += count as u64;
|
||||
if !result.oversized && result.bytes.len() + count <= MAX_LINE_BYTES {
|
||||
result.bytes.extend_from_slice(&buffer[..count]);
|
||||
} else {
|
||||
result.oversized = true;
|
||||
result.bytes.clear();
|
||||
if bytes.len() + count > MAX_BUFFERED_LINE_BYTES {
|
||||
// Derived struct deserializers use IgnoredAny for unknown fields:
|
||||
// Serde validates/skips large strings and arrays directly from the
|
||||
// reader, rather than constructing a String, Value, or whole line.
|
||||
let mut tail = JsonLineReader::new(reader);
|
||||
let prefix_non_whitespace = bytes.iter().any(|byte| !byte.is_ascii_whitespace());
|
||||
let record = serde_json::from_reader(bytes.as_slice().chain(&mut tail)).map(Some);
|
||||
if let Err(error) = &record {
|
||||
if let Some(kind) = error.io_error_kind() {
|
||||
return Err(std::io::Error::new(kind, "会话文件在读取期间发生错误"));
|
||||
}
|
||||
}
|
||||
// A bad record must not consume the next JSONL line, nor prevent it
|
||||
// from contributing usage. Also count all bytes against scan budget.
|
||||
tail.drain()?;
|
||||
return Ok(ParsedLine {
|
||||
record: if prefix_non_whitespace || tail.non_whitespace {
|
||||
record
|
||||
} else {
|
||||
Ok(None)
|
||||
},
|
||||
consumed: bytes.len() as u64 + tail.consumed,
|
||||
terminated: tail.terminated,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
reader.consume(count);
|
||||
if newline.is_some() {
|
||||
result.terminated = true;
|
||||
break;
|
||||
return Ok(ParsedLine {
|
||||
record: parse_record_slice(&bytes),
|
||||
consumed: bytes.len() as u64,
|
||||
terminated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_file(
|
||||
@@ -574,23 +745,19 @@ fn parse_file(
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let line = read_bounded_line(&mut reader).map_err(|error| io_error(path, error))?;
|
||||
let line = read_log_record(&mut reader).map_err(|error| io_error(path, error))?;
|
||||
if line.consumed == 0 {
|
||||
break;
|
||||
}
|
||||
*budget = budget.saturating_sub(line.consumed);
|
||||
if line.oversized {
|
||||
if !line.terminated {
|
||||
match line.record {
|
||||
Ok(Some(record)) => parsed.parse_record(record),
|
||||
Ok(None) => {}
|
||||
Err(error) if !line.terminated && error.is_eof() => {
|
||||
parsed.pending_line = true;
|
||||
break;
|
||||
}
|
||||
parsed.issues.oversized_lines += 1;
|
||||
} else if let Err(error) = parsed.parse_line(&line.bytes) {
|
||||
if !line.terminated && error.is_eof() {
|
||||
parsed.pending_line = true;
|
||||
break;
|
||||
}
|
||||
parsed.issues.malformed_lines += 1;
|
||||
Err(_) => parsed.issues.malformed_lines += 1,
|
||||
}
|
||||
offset += line.consumed;
|
||||
}
|
||||
@@ -698,6 +865,7 @@ struct SessionLog {
|
||||
fn combine_files(
|
||||
cache: &DirectoryCache,
|
||||
coverage: &mut UsageCoverage,
|
||||
indexed_identities: &HashMap<String, crate::sessions::UsageThreadIdentity>,
|
||||
) -> BTreeMap<String, SessionLog> {
|
||||
let mut sessions: BTreeMap<String, SessionLog> = BTreeMap::new();
|
||||
let mut issues = ParseIssues::default();
|
||||
@@ -748,7 +916,38 @@ fn combine_files(
|
||||
}
|
||||
session.events.extend(parsed.events.iter().cloned());
|
||||
}
|
||||
for session in sessions.values_mut() {
|
||||
let mut canonical_files: Option<HashMap<PathBuf, &CachedFile>> = None;
|
||||
for (id, session) in &mut sessions {
|
||||
// Keep token records from every copy, but use the current database's
|
||||
// actual rollout for identity. A stale archived/internal copy with the
|
||||
// same ID must not hide a user conversation or supply a different parent.
|
||||
let authoritative = indexed_identities
|
||||
.get(id)
|
||||
.and_then(|identity| identity.rollout_path.as_ref())
|
||||
.and_then(|path| {
|
||||
cache.files.get(path).or_else(|| {
|
||||
let canonical = path.canonicalize().ok()?;
|
||||
canonical_files
|
||||
.get_or_insert_with(|| {
|
||||
cache
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|(path, entry)| {
|
||||
path.canonicalize().ok().map(|path| (path, entry))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(&canonical)
|
||||
.copied()
|
||||
})
|
||||
})
|
||||
.map(|entry| &entry.parsed)
|
||||
.filter(|parsed| parsed.meta_seen && parsed.id.as_ref() == Some(id));
|
||||
if let Some(parsed) = authoritative {
|
||||
session.is_subagent = parsed.is_subagent;
|
||||
session.spawn_parent.clone_from(&parsed.spawn_parent);
|
||||
session.invalid_spawn_parent = parsed.invalid_spawn_parent;
|
||||
}
|
||||
// The same rollout can coexist in sessions and archived_sessions, or
|
||||
// have overlapping resume segments. Merge its token records once.
|
||||
let mut seen = HashSet::new();
|
||||
@@ -763,7 +962,6 @@ fn combine_files(
|
||||
}
|
||||
for (count, message) in [
|
||||
(issues.malformed_lines, "行日志无法解析"),
|
||||
(issues.oversized_lines, "行日志超过 2 MiB,已跳过"),
|
||||
(issues.invalid_usage, "条用量记录缺少有效的输入或输出计数"),
|
||||
(issues.invalid_timestamps, "条用量记录缺少有效时间"),
|
||||
(without_identity, "个会话文件缺少会话标识,已跳过"),
|
||||
@@ -946,7 +1144,7 @@ impl Bucket {
|
||||
|
||||
fn conversation_identities(
|
||||
sessions: &BTreeMap<String, SessionLog>,
|
||||
codex_dir: &Path,
|
||||
indexed_identities: HashMap<String, crate::sessions::UsageThreadIdentity>,
|
||||
) -> HashMap<String, crate::sessions::UsageThreadIdentity> {
|
||||
let mut identities = sessions
|
||||
.iter()
|
||||
@@ -958,18 +1156,24 @@ fn conversation_identities(
|
||||
classification_known: session.is_subagent,
|
||||
parent_id: session.spawn_parent.clone(),
|
||||
parent_conflict: session.invalid_spawn_parent,
|
||||
rollout_path: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
for (id, mut indexed) in crate::sessions::usage_thread_identities(codex_dir) {
|
||||
for (id, mut indexed) in indexed_identities {
|
||||
// Older indexes may have only id/title columns. Absence of a source
|
||||
// marker must not erase a positive subagent classification in the log.
|
||||
if !indexed.classification_known && identities.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
// The session manager's current SQLite classification is authoritative.
|
||||
// A source-only subagent marker may still need its rollout's parent ID.
|
||||
// A generic user/default database marker must not promote a thread
|
||||
// whose own rollout explicitly identifies it as an internal task.
|
||||
if identities.get(&id).is_some_and(|log| log.is_subagent) {
|
||||
indexed.is_subagent = true;
|
||||
indexed.classification_known = true;
|
||||
}
|
||||
// A source-only internal marker may still need its rollout's parent ID.
|
||||
if indexed.is_subagent && indexed.parent_id.is_none() && !indexed.parent_conflict {
|
||||
if let Some(log) = identities.get(&id) {
|
||||
indexed.parent_id.clone_from(&log.parent_id);
|
||||
@@ -1049,8 +1253,9 @@ where
|
||||
.and_then(|date| midnight(&timezone, date))
|
||||
.map(|time| time.with_timezone(&Utc));
|
||||
let range_end = now.with_timezone(&Utc);
|
||||
let sessions = combine_files(cache, &mut coverage);
|
||||
let identities = conversation_identities(&sessions, codex_dir);
|
||||
let indexed_identities = crate::sessions::usage_thread_identities(codex_dir);
|
||||
let sessions = combine_files(cache, &mut coverage, &indexed_identities);
|
||||
let identities = conversation_identities(&sessions, indexed_identities);
|
||||
let mut total = Bucket::default();
|
||||
let mut days: BTreeMap<NaiveDate, Bucket> = BTreeMap::new();
|
||||
let mut models: BTreeMap<String, Bucket> = BTreeMap::new();
|
||||
@@ -1886,7 +2091,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let changed = fixture.stats("all", None);
|
||||
assert_eq!(changed.sessions[0].id, "child");
|
||||
assert_eq!(changed.sessions[0].id, "main");
|
||||
assert_eq!(changed.totals.total_tokens, 55);
|
||||
}
|
||||
|
||||
@@ -1931,7 +2136,95 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fixture.stats("all", None).totals.session_count, 2);
|
||||
assert_eq!(fixture.stats("all", None).totals.session_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_and_memory_usage_stays_counted_without_creating_user_conversation_rows() {
|
||||
let mut fixture = Fixture::new();
|
||||
for (index, source) in [
|
||||
json!({"internal":"guardian"}),
|
||||
json!({"internal":"memory_consolidation"}),
|
||||
json!("internal_guardian"),
|
||||
json!("subagent_review"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("internal-{index}");
|
||||
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
|
||||
metadata["payload"]["source"] = source;
|
||||
fixture.write(
|
||||
&format!("sessions/{id}.jsonl"),
|
||||
&[
|
||||
metadata,
|
||||
context("codex-auto-review"),
|
||||
total("2026-09-08T01:00:01Z", 50, 5),
|
||||
],
|
||||
);
|
||||
}
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 220);
|
||||
assert_eq!(stats.totals.session_count, 0);
|
||||
assert!(stats.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_rollout_controls_classification_while_duplicate_usage_is_preserved() {
|
||||
let mut fixture = Fixture::new();
|
||||
let current = [
|
||||
meta("main", "2026-09-08T01:00:00Z", None),
|
||||
context("model"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
];
|
||||
fixture.write("sessions/current.jsonl", ¤t);
|
||||
let mut stale = current.to_vec();
|
||||
stale[0]["payload"]["source"] = json!({"internal":"guardian"});
|
||||
stale.push(total("2026-09-08T01:00:02Z", 150, 15));
|
||||
fixture.write("archived_sessions/stale-copy.jsonl", &stale);
|
||||
let db = rusqlite::Connection::open(fixture.dir.join("state_5.sqlite")).unwrap();
|
||||
db.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, thread_source TEXT, source TEXT, rollout_path TEXT);
|
||||
INSERT INTO threads VALUES ('main', 'User conversation', 'user', 'cli', 'sessions/current.jsonl');").unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 165);
|
||||
assert_eq!(stats.totals.session_count, 1);
|
||||
assert_eq!(stats.sessions.len(), 1);
|
||||
assert_eq!(stats.sessions[0].id, "main");
|
||||
assert_eq!(stats.sessions[0].totals.total_tokens, 165);
|
||||
|
||||
// An explicit internal source in the authoritative file still wins
|
||||
// over generic user/cli labels, even with a normal duplicate present.
|
||||
db.execute("UPDATE threads SET rollout_path = 'archived_sessions/stale-copy.jsonl' WHERE id = 'main'", []).unwrap();
|
||||
let internal = fixture.stats("all", None);
|
||||
assert_eq!(internal.totals.total_tokens, 165);
|
||||
assert_eq!(internal.totals.session_count, 0);
|
||||
assert!(internal.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_metadata_thread_source_hides_internal_tasks_without_database_markers() {
|
||||
let mut fixture = Fixture::new();
|
||||
for (index, source) in ["guardian_review", "memory_consolidation"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("internal-{index}");
|
||||
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
|
||||
metadata["payload"]["source"] = json!("cli");
|
||||
metadata["payload"]["thread_source"] = json!(source);
|
||||
fixture.write(
|
||||
&format!("sessions/{id}.jsonl"),
|
||||
&[
|
||||
metadata,
|
||||
context("model"),
|
||||
total("2026-09-08T01:00:01Z", 50, 5),
|
||||
],
|
||||
);
|
||||
}
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.totals.session_count, 0);
|
||||
assert!(stats.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2235,7 +2528,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_skips_large_lines_and_reports_bad_or_old_usage() {
|
||||
fn streaming_reader_recovers_after_large_invalid_lines_and_reports_bad_or_old_usage() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
@@ -2256,7 +2549,7 @@ mod tests {
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
file.write_all(b"{not json}\n").unwrap();
|
||||
file.write_all(&vec![b'x'; MAX_LINE_BYTES + 10]).unwrap();
|
||||
file.write_all(&vec![b'x'; 2 * 1024 * 1024 + 10]).unwrap();
|
||||
file.write_all(b"\n").unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
@@ -2264,7 +2557,7 @@ mod tests {
|
||||
.coverage
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("2 MiB")));
|
||||
.any(|warning| warning.contains("2 行日志无法解析")));
|
||||
assert!(stats
|
||||
.coverage
|
||||
.warnings
|
||||
@@ -2282,6 +2575,158 @@ mod tests {
|
||||
.any(|warning| warning.contains("有效的输入或输出")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_tool_image_and_token_records_keep_all_usage_without_coverage_warnings() {
|
||||
let mut fixture = Fixture::new();
|
||||
let huge = "x".repeat(2 * 1024 * 1024 + 100);
|
||||
// Field ordering is deliberate: counters/metadata can follow the large
|
||||
// value, so looking only at a record's prefix cannot recover usage.
|
||||
let text = format!(
|
||||
"{{\"payload\":{{\"instructions\":\"{huge}\",\"id\":\"one\"}},\"type\":\"session_meta\",\"timestamp\":\"2026-09-08T01:00:00Z\"}}\n\
|
||||
{{\"payload\":{{\"input_image\":{{\"image_url\":\"data:image/png;base64,{huge}\"}},\"output\":\"{huge}\"}},\"type\":\"response_item\"}}\n\
|
||||
{{\"payload\":{{\"instructions\":\"{huge}\",\"model\":\"gpt-large-fixture\"}},\"type\":\"turn_context\"}}\n\
|
||||
{{\"payload\":{{\"info\":{{\"ignored\":\"{huge}\",\"total_token_usage\":{{\"input_tokens\":100,\"cached_input_tokens\":20,\"output_tokens\":10,\"reasoning_output_tokens\":5}}}},\"type\":\"token_count\"}},\"type\":\"event_msg\",\"timestamp\":\"2026-09-08T01:00:01Z\"}}\n"
|
||||
);
|
||||
let path = fixture.dir.join("sessions/one.jsonl");
|
||||
fs::write(&path, text).unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.totals.cached_input_tokens, 20);
|
||||
assert_eq!(stats.totals.reasoning_tokens, 5);
|
||||
assert_eq!(stats.sessions[0].model, "gpt-large-fixture");
|
||||
assert!(stats.coverage.warnings.is_empty(), "{:?}", stats.coverage);
|
||||
// Appended events still resume at the exact boundary after large lines.
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:02Z", 150, 15)]);
|
||||
assert_eq!(fixture.stats("all", None).totals.total_tokens, 165);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_source_metadata_retains_agent_parent_without_materializing_extensions() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.write(
|
||||
"sessions/root.jsonl",
|
||||
&[
|
||||
meta("root", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
],
|
||||
);
|
||||
let mut child = spawn_meta("child", "root", "2026-09-08T01:00:02Z");
|
||||
child["payload"]["source"]["subagent"]["thread_spawn"]["instructions"] =
|
||||
json!("private extension ".repeat(140_000));
|
||||
fixture.write(
|
||||
"sessions/child.jsonl",
|
||||
&[
|
||||
child,
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:03Z", 50, 5),
|
||||
],
|
||||
);
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 165);
|
||||
assert_eq!(stats.sessions.len(), 1);
|
||||
assert_eq!(stats.sessions[0].id, "root");
|
||||
assert!(stats.coverage.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_broken_record_does_not_count_partial_tokens_or_consume_next_line() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
],
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
let mut bad = total("2026-09-08T01:00:01Z", 1000, 100);
|
||||
bad["large_ignored"] = json!("x".repeat(2 * 1024 * 1024 + 100));
|
||||
writeln!(file, "{bad} trailing garbage").unwrap();
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:02Z", 100, 10)]);
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.coverage.warnings.len(), 1);
|
||||
assert!(stats.coverage.warnings[0].contains("1 行日志无法解析"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_written_large_record_retries_then_counts_once_when_completed() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
],
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
write!(
|
||||
file,
|
||||
"{{\"ignored\":\"{}",
|
||||
"x".repeat(2 * 1024 * 1024 + 100)
|
||||
)
|
||||
.unwrap();
|
||||
let pending = fixture.stats("all", None);
|
||||
assert_eq!(pending.totals.total_tokens, 110);
|
||||
assert_eq!(pending.coverage.warnings.len(), 1);
|
||||
assert!(pending.coverage.warnings[0].contains("仍在写入"));
|
||||
let event = total("2026-09-08T01:00:02Z", 200, 20).to_string();
|
||||
// A complete final record is valid even before its newline is written.
|
||||
write!(file, "\",{}", &event[1..]).unwrap();
|
||||
let complete = fixture.stats("all", None);
|
||||
assert_eq!(complete.totals.total_tokens, 220);
|
||||
assert!(complete.coverage.warnings.is_empty());
|
||||
writeln!(file).unwrap();
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:03Z", 300, 30)]);
|
||||
assert_eq!(fixture.stats("all", None).totals.total_tokens, 330);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_line_boundaries_accept_escaped_content_and_large_blank_lines() {
|
||||
let huge = "\\\"中文\n".repeat(300_000);
|
||||
let mut event = total("2026-09-08T01:00:01Z", 100, 10);
|
||||
event["unknown"] = json!(huge);
|
||||
let text = format!("{}\n{event}\n", " ".repeat(2 * 1024 * 1024 + 10));
|
||||
let mut reader = BufReader::with_capacity(127, text.as_bytes());
|
||||
let blank = read_log_record(&mut reader).unwrap();
|
||||
assert!(blank.record.unwrap().is_none());
|
||||
assert!(blank.terminated);
|
||||
let record = read_log_record(&mut reader).unwrap();
|
||||
assert_eq!(record.record.unwrap().unwrap().payload.kind, "token_count");
|
||||
assert!(record.terminated);
|
||||
assert_eq!(blank.consumed + record.consumed, text.len() as u64);
|
||||
assert_eq!(read_log_record(&mut reader).unwrap().consumed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_budget_resumes_after_large_record_without_skipping_its_tokens() {
|
||||
let fixture = Fixture::new();
|
||||
let mut large = total("2026-09-08T01:00:01Z", 100, 10);
|
||||
large["unknown"] = json!("x".repeat(2 * 1024 * 1024 + 10));
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
large,
|
||||
total("2026-09-08T01:00:02Z", 150, 15),
|
||||
],
|
||||
);
|
||||
// Scan budgets stop between records, even if one large record crosses
|
||||
// the remaining budget. Its completed token event must be retained.
|
||||
let mut budget = MAX_BUFFERED_LINE_BYTES as u64;
|
||||
let partial = parse_file(&path, None, false, &mut budget).unwrap();
|
||||
assert!(!partial.complete);
|
||||
assert_eq!(partial.parsed.events.len(), 1);
|
||||
assert_eq!(budget, 0);
|
||||
let mut budget = MAX_SCAN_BYTES;
|
||||
let complete = parse_file(&path, Some(partial), false, &mut budget).unwrap();
|
||||
assert!(complete.complete);
|
||||
assert_eq!(complete.parsed.events.len(), 2);
|
||||
assert!(complete.parsed.issues.malformed_lines == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_timestamp_is_not_fabricated_from_file_modified_time() {
|
||||
let mut fixture = Fixture::new();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Windows staging/launching is deliberately separate from MSI service work.
|
||||
//! Only bytes already verified by tauri-plugin-updater reach this module.
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) struct StagedInstaller {
|
||||
dir: tempfile::TempDir,
|
||||
executable: PathBuf,
|
||||
}
|
||||
|
||||
fn is_windows_executable(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 64 || bytes.get(..2) != Some(b"MZ") {
|
||||
return false;
|
||||
}
|
||||
let offset = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize;
|
||||
offset >= 64
|
||||
&& offset
|
||||
.checked_add(4)
|
||||
.is_some_and(|end| bytes.get(offset..end) == Some(b"PE\0\0"))
|
||||
}
|
||||
|
||||
fn installer_arguments(pid: u32) -> [String; 4] {
|
||||
[
|
||||
"/P".into(),
|
||||
"/R".into(),
|
||||
"/UPDATE".into(),
|
||||
format!("/CODEXXPID={pid}"),
|
||||
]
|
||||
}
|
||||
|
||||
impl StagedInstaller {
|
||||
pub(super) fn create(verified_bytes: &[u8]) -> io::Result<Self> {
|
||||
if !is_windows_executable(verified_bytes) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"expected a verified Windows EXE installer",
|
||||
));
|
||||
}
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("Codex-X-update-")
|
||||
.tempdir()?;
|
||||
let executable = dir.path().join("Codex-X-setup.exe");
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&executable)?;
|
||||
file.write_all(verified_bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
Ok(Self { dir, executable })
|
||||
}
|
||||
|
||||
pub(super) fn launch(self, parent_pid: u32) -> io::Result<()> {
|
||||
if parent_pid == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"invalid parent PID",
|
||||
));
|
||||
}
|
||||
// No shell or interpolated command line: Unicode/space-containing user
|
||||
// paths are passed through Rust's native Windows argument quoting.
|
||||
let child = std::process::Command::new(&self.executable)
|
||||
.args(installer_arguments(parent_pid))
|
||||
.current_dir(self.dir.path())
|
||||
.spawn()?;
|
||||
// It owns its visible progress/errors from here, and waits for parent_pid
|
||||
// to exit before migrating or copying files. Never kill system installers.
|
||||
drop(child);
|
||||
let _ = self.dir.keep();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn staging_rejects_msi_html_truncated_and_invalid_pe_payloads() {
|
||||
for bytes in [
|
||||
b"\xd0\xcf\x11\xe0".as_slice(),
|
||||
b"<html>login</html>",
|
||||
b"MZ",
|
||||
&[0; 128],
|
||||
] {
|
||||
assert!(StagedInstaller::create(bytes).is_err());
|
||||
}
|
||||
let mut bytes = vec![0; 128];
|
||||
bytes[..2].copy_from_slice(b"MZ");
|
||||
bytes[60..64].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(StagedInstaller::create(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_staging_is_unique_and_cancel_cleans_only_its_directory() {
|
||||
let mut bytes = vec![0; 128];
|
||||
bytes[..2].copy_from_slice(b"MZ");
|
||||
bytes[60..64].copy_from_slice(&64u32.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(b"PE\0\0");
|
||||
let a = StagedInstaller::create(&bytes).unwrap();
|
||||
let b = StagedInstaller::create(&bytes).unwrap();
|
||||
assert_ne!(a.dir.path(), b.dir.path());
|
||||
assert_eq!(std::fs::read(&a.executable).unwrap(), bytes);
|
||||
let path = a.dir.path().to_owned();
|
||||
drop(a);
|
||||
assert!(!path.exists());
|
||||
assert!(b.executable.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_arguments_do_not_forward_user_cli_arguments_or_installer_paths() {
|
||||
assert_eq!(
|
||||
installer_arguments(123),
|
||||
["/P", "/R", "/UPDATE", "/CODEXXPID=123"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.16",
|
||||
"version": "0.3.23",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -35,6 +35,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -42,7 +45,18 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"targets": "all"
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"template": "nsis/installer.nsi",
|
||||
"languages": [
|
||||
"SimpChinese",
|
||||
"English"
|
||||
],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
+20
-261
@@ -1,270 +1,29 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
check as checkForTauriUpdate,
|
||||
type CheckOptions,
|
||||
type DownloadEvent,
|
||||
type DownloadOptions,
|
||||
type Update,
|
||||
} from "@tauri-apps/plugin-updater";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import { check as checkForTauriUpdate } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { AppUpdaterController, type AppUpdateEvent } from "./appUpdaterController";
|
||||
|
||||
export type AppUpdaterPhase =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "downloading"
|
||||
| "installing"
|
||||
| "ready"
|
||||
| "error";
|
||||
export { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy } from "./appUpdaterController";
|
||||
export type { AppUpdaterPhase, AppUpdaterState, AppUpdaterFailure, AppUpdaterCheckResult, AppUpdaterCheckOptions } from "./appUpdaterController";
|
||||
|
||||
export type AppUpdaterFailure = "check" | "download" | "install" | "restart" | null;
|
||||
|
||||
export type AppUpdaterState = Readonly<{
|
||||
phase: AppUpdaterPhase;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
notes: string | null;
|
||||
publishedAt: string | null;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
failure: AppUpdaterFailure;
|
||||
}>;
|
||||
|
||||
export type AppUpdaterCheckResult = "available" | "up-to-date" | "error";
|
||||
|
||||
export type AppUpdaterCheckOptions = CheckOptions & {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
|
||||
phase: "idle",
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
type RetryAction = "check" | "update" | "restart";
|
||||
|
||||
function normalizeByteCount(value: number | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
class AppUpdaterController {
|
||||
private state: AppUpdaterState = INITIAL_APP_UPDATER_STATE;
|
||||
private readonly listeners = new Set<Listener>();
|
||||
private update: Update | null = null;
|
||||
private checkPromise: Promise<AppUpdaterCheckResult> | null = null;
|
||||
private updatePromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private restartPromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private retryAction: RetryAction = "check";
|
||||
|
||||
readonly getSnapshot = (): AppUpdaterState => this.state;
|
||||
|
||||
readonly subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
readonly check = (options: AppUpdaterCheckOptions = {}): Promise<AppUpdaterCheckResult> => {
|
||||
if (this.checkPromise) return this.checkPromise;
|
||||
|
||||
if (this.isUpdateBusy()) {
|
||||
return Promise.resolve(this.update ? "available" : "error");
|
||||
}
|
||||
|
||||
if (this.update && !options.force) return Promise.resolve("available");
|
||||
|
||||
const { force: _force, ...pluginOptions } = options;
|
||||
this.checkPromise = this.performCheck(pluginOptions).finally(() => {
|
||||
this.checkPromise = null;
|
||||
export const appUpdater = new AppUpdaterController({
|
||||
check: checkForTauriUpdate,
|
||||
install: async (update, onEvent, options) => {
|
||||
const channel = new Channel<AppUpdateEvent>(onEvent);
|
||||
return invoke<{ restartRequired: boolean }>("install_app_update", {
|
||||
updateRid: update.rid,
|
||||
onEvent: channel,
|
||||
timeout: options?.timeout,
|
||||
headers: options?.headers ? Array.from(new Headers(options.headers).entries()) : undefined,
|
||||
});
|
||||
return this.checkPromise;
|
||||
};
|
||||
|
||||
readonly downloadAndInstall = (options?: DownloadOptions): Promise<AppUpdaterPhase> => {
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
if (this.state.phase === "ready") return Promise.resolve("ready");
|
||||
|
||||
if (!this.update) {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return Promise.resolve("error");
|
||||
}
|
||||
|
||||
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => {
|
||||
this.updatePromise = null;
|
||||
});
|
||||
return this.updatePromise;
|
||||
};
|
||||
|
||||
readonly retry = (): Promise<AppUpdaterCheckResult | AppUpdaterPhase> => {
|
||||
if (this.retryAction === "restart") return this.restart();
|
||||
if (this.retryAction === "update" && this.update) return this.downloadAndInstall();
|
||||
return this.check({ force: true });
|
||||
};
|
||||
|
||||
readonly restart = (): Promise<AppUpdaterPhase> => {
|
||||
if (this.restartPromise) return this.restartPromise;
|
||||
if (this.state.phase !== "ready" && this.state.failure !== "restart") {
|
||||
return Promise.resolve(this.state.phase);
|
||||
}
|
||||
|
||||
this.restartPromise = this.performRestart().finally(() => {
|
||||
this.restartPromise = null;
|
||||
});
|
||||
return this.restartPromise;
|
||||
};
|
||||
|
||||
private isUpdateBusy(): boolean {
|
||||
return this.state.phase === "downloading" || this.state.phase === "installing";
|
||||
}
|
||||
|
||||
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
|
||||
this.retryAction = "check";
|
||||
this.setState({
|
||||
phase: "checking",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const nextUpdate = await checkForTauriUpdate(options);
|
||||
if (!nextUpdate) {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({
|
||||
phase: "idle",
|
||||
latestVersion: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
failure: null,
|
||||
});
|
||||
return "up-to-date";
|
||||
}
|
||||
|
||||
await this.replaceUpdate(nextUpdate);
|
||||
this.retryAction = "update";
|
||||
this.setState({
|
||||
phase: "available",
|
||||
currentVersion: nextUpdate.currentVersion,
|
||||
latestVersion: nextUpdate.version,
|
||||
notes: nextUpdate.body?.trim() || null,
|
||||
publishedAt: nextUpdate.date || null,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
return "available";
|
||||
} catch {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async performDownloadAndInstall(update: Update, options?: DownloadOptions): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "update";
|
||||
let downloadFinished = false;
|
||||
this.setState({
|
||||
phase: "downloading",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Finished") downloadFinished = true;
|
||||
this.handleDownloadEvent(event);
|
||||
}, options);
|
||||
this.setState({
|
||||
phase: "ready",
|
||||
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
|
||||
failure: null,
|
||||
});
|
||||
return "ready";
|
||||
} catch {
|
||||
const failedDuringInstall = downloadFinished || this.state.phase === "installing";
|
||||
this.setState({
|
||||
phase: "error",
|
||||
failure: failedDuringInstall ? "install" : "download",
|
||||
});
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private handleDownloadEvent(event: DownloadEvent): void {
|
||||
if (event.event === "Started") {
|
||||
this.setState({
|
||||
phase: "downloading",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: normalizeByteCount(event.data.contentLength),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === "Progress") {
|
||||
const nextBytes = this.state.downloadedBytes + Math.max(0, event.data.chunkLength);
|
||||
this.setState({
|
||||
downloadedBytes: this.state.totalBytes === null
|
||||
? nextBytes
|
||||
: Math.min(nextBytes, this.state.totalBytes),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
phase: "installing",
|
||||
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
|
||||
});
|
||||
}
|
||||
|
||||
private async performRestart(): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "restart";
|
||||
this.setState({ failure: null });
|
||||
try {
|
||||
await relaunch();
|
||||
return "ready";
|
||||
} catch {
|
||||
this.setState({ phase: "error", failure: "restart" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceUpdate(nextUpdate: Update | null): Promise<void> {
|
||||
const previousUpdate = this.update;
|
||||
this.update = nextUpdate;
|
||||
if (!previousUpdate || previousUpdate === nextUpdate) return;
|
||||
|
||||
try {
|
||||
await previousUpdate.close();
|
||||
} catch (error) {
|
||||
console.warn("Unable to release the previous updater resource", error);
|
||||
}
|
||||
}
|
||||
|
||||
private setState(patch: Partial<AppUpdaterState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
export const appUpdater = new AppUpdaterController();
|
||||
},
|
||||
restart: relaunch,
|
||||
setTimer: (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimer: (timer) => window.clearTimeout(timer as number),
|
||||
});
|
||||
|
||||
export function useAppUpdater() {
|
||||
const state = useSyncExternalStore(appUpdater.subscribe, appUpdater.getSnapshot, appUpdater.getSnapshot);
|
||||
return {
|
||||
state,
|
||||
check: appUpdater.check,
|
||||
downloadAndInstall: appUpdater.downloadAndInstall,
|
||||
retry: appUpdater.retry,
|
||||
restart: appUpdater.restart,
|
||||
} as const;
|
||||
return { state, check: appUpdater.check, downloadAndInstall: appUpdater.downloadAndInstall, retry: appUpdater.retry, restart: appUpdater.restart } as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { CheckOptions, DownloadOptions } from "@tauri-apps/plugin-updater";
|
||||
|
||||
export type AppUpdaterPhase = "idle" | "checking" | "available" | "downloading" | "verifying" | "preparing" | "installing" | "handed-off" | "ready" | "error";
|
||||
export type AppUpdaterFailure = "check" | "download" | "verify" | "prepare" | "install" | "restart" | null;
|
||||
export type AppUpdaterState = Readonly<{
|
||||
phase: AppUpdaterPhase;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
notes: string | null;
|
||||
publishedAt: string | null;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
failure: AppUpdaterFailure;
|
||||
errorMessage: string | null;
|
||||
logPath: string | null;
|
||||
takingLonger: boolean;
|
||||
}>;
|
||||
export type AppUpdaterCheckResult = "available" | "up-to-date" | "error";
|
||||
export type AppUpdaterCheckOptions = CheckOptions & { force?: boolean };
|
||||
export type AppUpdateEvent =
|
||||
| { event: "Started"; data: { contentLength?: number } }
|
||||
| { event: "Progress"; data: { chunkLength: number } }
|
||||
| { event: "Verifying" | "Preparing" | "Installing" | "HandedOff" };
|
||||
export type CheckedAppUpdate = {
|
||||
rid: number;
|
||||
currentVersion: string;
|
||||
version: string;
|
||||
body?: string;
|
||||
date?: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
export type AppUpdaterDependencies = {
|
||||
check: (options: CheckOptions) => Promise<CheckedAppUpdate | null>;
|
||||
install: (update: CheckedAppUpdate, onEvent: (event: AppUpdateEvent) => void, options?: DownloadOptions) => Promise<{ restartRequired: boolean }>;
|
||||
restart: () => Promise<void>;
|
||||
setTimer: (callback: () => void, delay: number) => unknown;
|
||||
clearTimer: (timer: unknown) => void;
|
||||
};
|
||||
export const UPDATER_SLOW_NOTICE_MS = 30_000;
|
||||
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
|
||||
phase: "idle", currentVersion: null, latestVersion: null, notes: null, publishedAt: null,
|
||||
downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false,
|
||||
};
|
||||
export function isAppUpdateBusy(phase: AppUpdaterPhase): boolean {
|
||||
return ["downloading", "verifying", "preparing", "installing", "handed-off"].includes(phase);
|
||||
}
|
||||
function normalizeByteCount(value: number | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
function installationError(error: unknown, fallback: AppUpdaterFailure) {
|
||||
// Only the app's structured installation command supplies user-facing details.
|
||||
// Do not render arbitrary plugin/network errors, which can include private URLs.
|
||||
if (typeof error === "object" && error !== null && "stage" in error && "message" in error) {
|
||||
const data = error as Record<string, unknown>;
|
||||
const stage = ["download", "verify", "prepare", "install"].includes(String(data.stage))
|
||||
? data.stage as AppUpdaterFailure : fallback;
|
||||
return {
|
||||
failure: stage,
|
||||
errorMessage: typeof data.message === "string" ? data.message.slice(0, 1000) : null,
|
||||
logPath: typeof data.logPath === "string" ? data.logPath : null,
|
||||
};
|
||||
}
|
||||
return { failure: fallback, errorMessage: null, logPath: null };
|
||||
}
|
||||
|
||||
export class AppUpdaterController {
|
||||
private state: AppUpdaterState = INITIAL_APP_UPDATER_STATE;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly dependencies: AppUpdaterDependencies;
|
||||
private update: CheckedAppUpdate | null = null;
|
||||
private checkPromise: Promise<AppUpdaterCheckResult> | null = null;
|
||||
private updatePromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private restartPromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private retryAction: "check" | "update" | "restart" = "check";
|
||||
private slowTimer: unknown = null;
|
||||
private attempt = 0;
|
||||
|
||||
constructor(dependencies: AppUpdaterDependencies) { this.dependencies = dependencies; }
|
||||
readonly getSnapshot = (): AppUpdaterState => this.state;
|
||||
readonly subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
};
|
||||
readonly check = (options: AppUpdaterCheckOptions = {}): Promise<AppUpdaterCheckResult> => {
|
||||
if (this.checkPromise) return this.checkPromise;
|
||||
if (isAppUpdateBusy(this.state.phase) || this.state.phase === "ready") {
|
||||
return Promise.resolve(this.update ? "available" : "error");
|
||||
}
|
||||
if (this.update && !options.force) return Promise.resolve("available");
|
||||
const { force: _force, ...pluginOptions } = options;
|
||||
this.checkPromise = this.performCheck(pluginOptions).finally(() => { this.checkPromise = null; });
|
||||
return this.checkPromise;
|
||||
};
|
||||
readonly downloadAndInstall = (options?: DownloadOptions): Promise<AppUpdaterPhase> => {
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
if (this.checkPromise) return Promise.resolve(this.state.phase);
|
||||
if (this.state.phase === "ready" || this.state.phase === "handed-off") return Promise.resolve(this.state.phase);
|
||||
if (!this.update) {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return Promise.resolve("error");
|
||||
}
|
||||
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => { this.updatePromise = null; });
|
||||
return this.updatePromise;
|
||||
};
|
||||
readonly retry = (): Promise<AppUpdaterCheckResult | AppUpdaterPhase> => {
|
||||
if (this.retryAction === "restart") return this.restart();
|
||||
if (this.retryAction === "update" && this.update) return this.downloadAndInstall();
|
||||
return this.check({ force: true });
|
||||
};
|
||||
readonly restart = (): Promise<AppUpdaterPhase> => {
|
||||
if (this.restartPromise) return this.restartPromise;
|
||||
if (this.state.phase !== "ready" && this.state.failure !== "restart") return Promise.resolve(this.state.phase);
|
||||
this.restartPromise = this.performRestart().finally(() => { this.restartPromise = null; });
|
||||
return this.restartPromise;
|
||||
};
|
||||
|
||||
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "checking", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false });
|
||||
try {
|
||||
const nextUpdate = await this.dependencies.check(options);
|
||||
await this.replaceUpdate(nextUpdate);
|
||||
if (!nextUpdate) {
|
||||
this.setState({ phase: "idle", latestVersion: null, notes: null, publishedAt: null });
|
||||
return "up-to-date";
|
||||
}
|
||||
this.retryAction = "update";
|
||||
this.setState({ phase: "available", currentVersion: nextUpdate.currentVersion, latestVersion: nextUpdate.version,
|
||||
notes: nextUpdate.body?.trim() || null, publishedAt: nextUpdate.date || null });
|
||||
return "available";
|
||||
} catch {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async performDownloadAndInstall(update: CheckedAppUpdate, options?: DownloadOptions): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "update";
|
||||
const attempt = ++this.attempt;
|
||||
let acceptingEvents = true;
|
||||
let handedOff = false;
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, logPath: null, takingLonger: false });
|
||||
this.armSlowNotice();
|
||||
try {
|
||||
const result = await this.dependencies.install(update, (event) => {
|
||||
if (!acceptingEvents || attempt !== this.attempt || handedOff) return;
|
||||
if (event.event === "HandedOff") handedOff = true;
|
||||
this.handleInstallEvent(event);
|
||||
}, options);
|
||||
// Windows can leave this WebView alive briefly after handing off. The
|
||||
// installer has started, but its success is not yet known; never relaunch it.
|
||||
const phase = handedOff || !result.restartRequired ? "handed-off" : "ready";
|
||||
this.setState({ phase, failure: null, takingLonger: false });
|
||||
if (phase === "handed-off") this.armSlowNotice();
|
||||
return phase;
|
||||
} catch (error) {
|
||||
if (handedOff) return "handed-off";
|
||||
const fallback = this.state.phase === "verifying" ? "verify" : this.state.phase === "preparing" ? "prepare"
|
||||
: this.state.phase === "installing" ? "install" : "download";
|
||||
this.setState({ phase: "error", ...installationError(error, fallback), takingLonger: false });
|
||||
return "error";
|
||||
} finally {
|
||||
acceptingEvents = false;
|
||||
if (this.state.phase !== "handed-off") this.clearSlowNotice();
|
||||
}
|
||||
}
|
||||
private handleInstallEvent(event: AppUpdateEvent): void {
|
||||
if (event.event === "Started") {
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: normalizeByteCount(event.data.contentLength) });
|
||||
} else if (event.event === "Progress") {
|
||||
const bytes = normalizeByteCount(event.data.chunkLength);
|
||||
if (bytes === null) return;
|
||||
const next = this.state.downloadedBytes + bytes;
|
||||
this.setState({ downloadedBytes: this.state.totalBytes === null ? next : Math.min(next, this.state.totalBytes) });
|
||||
} else {
|
||||
const phase = { Verifying: "verifying", Preparing: "preparing", Installing: "installing", HandedOff: "handed-off" }[event.event] as AppUpdaterPhase;
|
||||
this.setState({ phase, downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes });
|
||||
}
|
||||
this.armSlowNotice();
|
||||
}
|
||||
private armSlowNotice(): void {
|
||||
this.clearSlowNotice();
|
||||
this.setState({ takingLonger: false });
|
||||
this.slowTimer = this.dependencies.setTimer(() => {
|
||||
this.slowTimer = null;
|
||||
if (isAppUpdateBusy(this.state.phase)) this.setState({ takingLonger: true });
|
||||
}, UPDATER_SLOW_NOTICE_MS);
|
||||
}
|
||||
private clearSlowNotice(): void {
|
||||
if (this.slowTimer !== null) this.dependencies.clearTimer(this.slowTimer);
|
||||
this.slowTimer = null;
|
||||
}
|
||||
private async performRestart(): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "restart";
|
||||
this.setState({ failure: null, errorMessage: null, logPath: null });
|
||||
try { await this.dependencies.restart(); this.setState({ phase: "ready" }); return "ready"; }
|
||||
catch { this.setState({ phase: "error", failure: "restart" }); return "error"; }
|
||||
}
|
||||
private async replaceUpdate(next: CheckedAppUpdate | null): Promise<void> {
|
||||
const previous = this.update;
|
||||
this.update = next;
|
||||
if (previous && previous !== next) {
|
||||
try { await previous.close(); }
|
||||
catch { console.warn("Unable to release the previous updater resource"); }
|
||||
}
|
||||
}
|
||||
private setState(patch: Partial<AppUpdaterState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { INITIAL_APP_UPDATER_STATE, type AppUpdaterState } from "../appUpdater";
|
||||
import { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy, type AppUpdaterState } from "../appUpdater";
|
||||
import type { Lang, StartupDiagnostics } from "../types";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
|
||||
@@ -101,7 +102,7 @@ export function UpdateDialog({
|
||||
latestVersion: latestVersion ?? null,
|
||||
};
|
||||
const phase = updaterState.phase;
|
||||
const isBusy = phase === "downloading" || phase === "installing";
|
||||
const isBusy = isAppUpdateBusy(phase);
|
||||
const totalBytes = updaterState.totalBytes;
|
||||
const hasKnownProgress = totalBytes !== null && totalBytes > 0;
|
||||
const progress = totalBytes !== null && totalBytes > 0
|
||||
@@ -118,8 +119,14 @@ export function UpdateDialog({
|
||||
: "检测到新版本,可前往下载页获取对应平台的安装包。",
|
||||
downloadingTitle: "正在下载更新",
|
||||
downloadingDescription: "请保持 Codex-X 打开,下载完成后会自动安装。",
|
||||
installingTitle: "正在安装更新",
|
||||
installingDescription: "即将完成,请暂时不要关闭软件。",
|
||||
verifyingTitle: "正在验证安装包",
|
||||
verifyingDescription: "下载已完成,正在确认安装包完整可靠。",
|
||||
preparingTitle: "正在准备更新",
|
||||
preparingDescription: "正在保存状态并恢复连接配置,完成后会自动退出并开始安装。",
|
||||
installingTitle: "正在启动安装程序",
|
||||
installingDescription: "请稍候。如系统询问是否允许安装,请确认授权。",
|
||||
handedOffTitle: "安装程序已启动",
|
||||
handedOffDescription: "Codex-X 即将退出,请在安装窗口继续。首次升级旧版时,系统可能需要一次管理员授权。",
|
||||
readyTitle: "更新已准备好",
|
||||
readyDescription: "重新启动 Codex-X 即可使用新版本。",
|
||||
errorTitle: "更新没有完成",
|
||||
@@ -134,7 +141,16 @@ export function UpdateDialog({
|
||||
close: "关闭",
|
||||
updateNow: "立即更新",
|
||||
downloading: "正在下载",
|
||||
installing: "正在安装",
|
||||
verifying: "验证安装包",
|
||||
preparing: "准备退出",
|
||||
installing: "启动安装程序",
|
||||
handedOff: "等待安装程序",
|
||||
slowTitle: "这一步比平时慢一些",
|
||||
slowDescription: phase === "downloading"
|
||||
? "暂时没有收到新的下载数据,正在等待网络恢复。请勿重复点击更新。"
|
||||
: "仍在等待当前步骤完成。请查看是否有系统授权或安装窗口等待操作,不要重复启动安装。",
|
||||
detailLabel: "失败原因",
|
||||
logLabel: "安装日志",
|
||||
restart: "重新启动",
|
||||
retry: "重试",
|
||||
downloadPage: "打开下载页",
|
||||
@@ -149,8 +165,14 @@ export function UpdateDialog({
|
||||
: "A new version is available from the download page for your platform.",
|
||||
downloadingTitle: "Downloading update",
|
||||
downloadingDescription: "Keep Codex-X open. Installation starts automatically after download.",
|
||||
installingTitle: "Installing update",
|
||||
installingDescription: "Almost done. Please keep the app open.",
|
||||
verifyingTitle: "Verifying installer",
|
||||
verifyingDescription: "Download complete. Checking the installer before making changes.",
|
||||
preparingTitle: "Preparing update",
|
||||
preparingDescription: "Saving state and restoring connection settings before exiting to install.",
|
||||
installingTitle: "Starting installer",
|
||||
installingDescription: "Please wait. Approve the installation if your system asks for permission.",
|
||||
handedOffTitle: "Installer started",
|
||||
handedOffDescription: "Codex-X will exit. Continue in the installer window. Upgrading an older installation may ask for administrator permission once.",
|
||||
readyTitle: "Update is ready",
|
||||
readyDescription: "Restart Codex-X to use the new version.",
|
||||
errorTitle: "Update did not finish",
|
||||
@@ -165,39 +187,39 @@ export function UpdateDialog({
|
||||
close: "Close",
|
||||
updateNow: "Update now",
|
||||
downloading: "Downloading",
|
||||
installing: "Installing",
|
||||
verifying: "Verifying installer",
|
||||
preparing: "Preparing to exit",
|
||||
installing: "Starting installer",
|
||||
handedOff: "Waiting for installer",
|
||||
slowTitle: "Taking a little longer",
|
||||
slowDescription: phase === "downloading"
|
||||
? "Waiting for more download data. Please do not start another update."
|
||||
: "Still waiting for this step to finish. Check for a system permission or installer window requiring your attention. Do not start another installation.",
|
||||
detailLabel: "What went wrong",
|
||||
logLabel: "Installation log",
|
||||
restart: "Restart",
|
||||
retry: "Try again",
|
||||
downloadPage: "Open download page",
|
||||
releaseNotes: "What's new",
|
||||
};
|
||||
|
||||
const title = phase === "checking"
|
||||
? copy.checkingTitle
|
||||
: phase === "available"
|
||||
? copy.availableTitle
|
||||
: phase === "downloading"
|
||||
? copy.downloadingTitle
|
||||
: phase === "installing"
|
||||
? copy.installingTitle
|
||||
: phase === "ready"
|
||||
? copy.readyTitle
|
||||
: phase === "error"
|
||||
? copy.errorTitle
|
||||
: copy.idleTitle;
|
||||
const description = phase === "checking"
|
||||
? copy.checkingDescription
|
||||
: phase === "available"
|
||||
? copy.availableDescription
|
||||
: phase === "downloading"
|
||||
? copy.downloadingDescription
|
||||
: phase === "installing"
|
||||
? copy.installingDescription
|
||||
: phase === "ready"
|
||||
? copy.readyDescription
|
||||
: phase === "error"
|
||||
? copy.errorDescription
|
||||
: copy.idleDescription;
|
||||
const phaseCopy = {
|
||||
checking: [copy.checkingTitle, copy.checkingDescription],
|
||||
available: [copy.availableTitle, copy.availableDescription],
|
||||
downloading: [copy.downloadingTitle, copy.downloadingDescription],
|
||||
verifying: [copy.verifyingTitle, copy.verifyingDescription],
|
||||
preparing: [copy.preparingTitle, copy.preparingDescription],
|
||||
installing: [copy.installingTitle, copy.installingDescription],
|
||||
"handed-off": [copy.handedOffTitle, copy.handedOffDescription],
|
||||
ready: [copy.readyTitle, copy.readyDescription],
|
||||
error: [copy.errorTitle, copy.errorDescription],
|
||||
idle: [copy.idleTitle, copy.idleDescription],
|
||||
};
|
||||
const [title, description] = phaseCopy[phase];
|
||||
const progressLabel = phase === "downloading" ? copy.downloading
|
||||
: phase === "verifying" ? copy.verifying : phase === "preparing" ? copy.preparing
|
||||
: phase === "handed-off" ? copy.handedOff : phase === "ready" ? copy.restart : copy.installing;
|
||||
const indeterminate = isBusy && (phase !== "downloading" || progress === null);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isBusy) onClose();
|
||||
@@ -238,7 +260,7 @@ export function UpdateDialog({
|
||||
: isBusy
|
||||
? (
|
||||
<Button disabled icon={<Loader2 className="spin" size={16} />}>
|
||||
{phase === "downloading" ? copy.downloading : copy.installing}
|
||||
{progressLabel}
|
||||
</Button>
|
||||
)
|
||||
: <Button variant="secondary" onClick={handleClose}>{copy.close}</Button>;
|
||||
@@ -271,10 +293,10 @@ export function UpdateDialog({
|
||||
<div><dt>{copy.latest}</dt><dd>{updaterState.latestVersion || latestVersion || "-"}</dd></div>
|
||||
</dl>
|
||||
|
||||
{(phase === "downloading" || phase === "installing" || phase === "ready") && (
|
||||
{(isBusy || phase === "ready") && (
|
||||
<div className="cx-update-progress" aria-live="polite">
|
||||
<div className="cx-update-progress-copy">
|
||||
<span>{phase === "downloading" ? copy.downloading : phase === "installing" ? copy.installing : copy.restart}</span>
|
||||
<span>{progressLabel}</span>
|
||||
<strong>
|
||||
{phase === "downloading"
|
||||
? hasKnownProgress
|
||||
@@ -288,18 +310,31 @@ export function UpdateDialog({
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className={`cx-update-progress-track${progress === null && phase === "downloading" ? " cx-update-progress-track--indeterminate" : ""}`}
|
||||
className={`cx-update-progress-track${indeterminate ? " cx-update-progress-track--indeterminate" : ""}`}
|
||||
role="progressbar"
|
||||
aria-label={phase === "downloading" ? copy.downloading : copy.installing}
|
||||
aria-label={progressLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={phase === "ready" ? 100 : progress ?? undefined}
|
||||
aria-valuenow={phase === "ready" ? 100 : indeterminate ? undefined : progress ?? undefined}
|
||||
>
|
||||
<span style={{ width: phase === "ready" || phase === "installing" ? "100%" : progress === null ? "38%" : `${progress}%` }} />
|
||||
<span style={{ width: phase === "ready" ? "100%" : indeterminate ? "38%" : `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updaterState.takingLonger && isBusy && (
|
||||
<section className="cx-update-notes" role="status">
|
||||
<strong>{copy.slowTitle}</strong>
|
||||
<p>{copy.slowDescription}</p>
|
||||
</section>
|
||||
)}
|
||||
{phase === "error" && updaterState.errorMessage && (
|
||||
<section className="cx-update-notes" role="alert">
|
||||
<strong>{copy.detailLabel}</strong>
|
||||
<p>{updaterState.errorMessage}</p>
|
||||
{updaterState.logPath && <p className="cx-update-log-path">{copy.logLabel}: {updaterState.logPath}</p>}
|
||||
</section>
|
||||
)}
|
||||
{updaterState.notes && phase !== "checking" && (
|
||||
<section className="cx-update-notes">
|
||||
<strong>{copy.releaseNotes}</strong>
|
||||
@@ -313,8 +348,11 @@ export function UpdateDialog({
|
||||
export type StartupWizardDialogProps = {
|
||||
open: boolean;
|
||||
closing: boolean;
|
||||
mode?: "startup" | "manual";
|
||||
lang: Lang;
|
||||
diagnostics: StartupDiagnostics | null;
|
||||
diagnosticsError?: string;
|
||||
configHealthPanel?: ReactNode;
|
||||
configDir: string;
|
||||
loading: boolean;
|
||||
onConfigDirChange: (value: string) => void;
|
||||
@@ -327,8 +365,11 @@ export type StartupWizardDialogProps = {
|
||||
export function StartupWizardDialog({
|
||||
open,
|
||||
closing,
|
||||
mode = "startup",
|
||||
lang,
|
||||
diagnostics,
|
||||
diagnosticsError,
|
||||
configHealthPanel,
|
||||
configDir,
|
||||
loading,
|
||||
onConfigDirChange,
|
||||
@@ -338,48 +379,76 @@ export function StartupWizardDialog({
|
||||
onEnter,
|
||||
}: StartupWizardDialogProps) {
|
||||
const isChinese = lang === "zh";
|
||||
if (!diagnostics) return null;
|
||||
const isManual = mode === "manual";
|
||||
const recheckButton = (
|
||||
<Button
|
||||
variant={isManual ? "primary" : "secondary"}
|
||||
icon={<RefreshCw size={16} className={loading ? "spin" : undefined} />}
|
||||
onClick={onRecheck}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (isChinese ? "正在检查" : "Checking") : (isChinese ? "重新检查" : "Recheck")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
open={open}
|
||||
onClose={onSkip}
|
||||
size="lg"
|
||||
title={isChinese ? "首次启动向导" : "First-run wizard"}
|
||||
description={diagnostics.summary}
|
||||
showCloseButton={false}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
title={isChinese ? "环境与配置检查" : "Environment & configuration check"}
|
||||
description={isManual
|
||||
? (isChinese ? "查看环境与配置状态,发现问题后可选择修复。" : "Review your environment and configuration, and choose whether to repair any issues.")
|
||||
: (isChinese ? "首次使用前,检查 Codex 环境与配置是否就绪。" : "Before you get started, check whether your Codex environment and configuration are ready.")}
|
||||
showCloseButton={isManual}
|
||||
closeLabel={isChinese ? "关闭" : "Close"}
|
||||
closeOnBackdrop={isManual}
|
||||
closeOnEscape={isManual}
|
||||
className={closing ? "cx-startup-dialog cx-startup-dialog--closing" : "cx-startup-dialog"}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={onSkip}>{isChinese ? "跳过" : "Skip"}</Button>
|
||||
<Button variant="secondary" icon={<Settings size={16} />} onClick={onOpenSettings}>{isChinese ? "去设置" : "Settings"}</Button>
|
||||
<Button icon={<CheckCircle2 size={16} />} onClick={onEnter}>{isChinese ? "进入 Codex-X" : "Enter Codex-X"}</Button>
|
||||
</>
|
||||
isManual ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onSkip}>{isChinese ? "关闭" : "Close"}</Button>
|
||||
{recheckButton}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={onSkip}>{isChinese ? "跳过" : "Skip"}</Button>
|
||||
<Button variant="secondary" icon={<Settings size={16} />} onClick={onOpenSettings}>{isChinese ? "去设置" : "Settings"}</Button>
|
||||
<Button icon={<CheckCircle2 size={16} />} onClick={onEnter}>{isChinese ? "进入 Codex-X" : "Enter Codex-X"}</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div className="cx-startup-path-control">
|
||||
<div className={`cx-startup-path-control${isManual ? " cx-startup-path-control--manual" : ""}`}>
|
||||
<label htmlFor="cx-startup-codex-home">CODEX_HOME</label>
|
||||
<input
|
||||
id="cx-startup-codex-home"
|
||||
value={configDir || diagnostics.codexDir}
|
||||
value={configDir}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder="~/.codex"
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<RefreshCw size={16} className={loading ? "spin" : undefined} />}
|
||||
onClick={onRecheck}
|
||||
disabled={loading}
|
||||
>
|
||||
{isChinese ? "重新检测" : "Recheck"}
|
||||
</Button>
|
||||
{!isManual && recheckButton}
|
||||
</div>
|
||||
|
||||
<div className="cx-startup-checks">
|
||||
{diagnosticsError && <div className="cx-startup-diagnostics-notice cx-startup-diagnostics-notice--error" role="alert">
|
||||
<AlertCircle size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{isChinese ? "环境检查未完成" : "Environment check did not finish"}</strong>
|
||||
<p>{diagnosticsError}</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{!diagnostics && !diagnosticsError && <div className="cx-startup-diagnostics-notice" role="status" aria-live="polite">
|
||||
{loading ? <Loader2 className="spin" size={17} aria-hidden="true" /> : <RefreshCw size={17} aria-hidden="true" />}
|
||||
<p>{loading
|
||||
? (isChinese ? "正在检查 Codex 环境…" : "Checking your Codex environment…")
|
||||
: (isChinese ? "点击重新检查,查看当前环境状态。" : "Choose Recheck to see the current environment status.")}</p>
|
||||
</div>}
|
||||
|
||||
{diagnostics && <div className="cx-startup-checks">
|
||||
{diagnostics.items.map((item) => {
|
||||
const isOk = item.status === "ok";
|
||||
const isManual = item.status === "manual";
|
||||
@@ -403,7 +472,9 @@ export function StartupWizardDialog({
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{configHealthPanel && <div className="cx-startup-config-health">{configHealthPanel}</div>}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { AppUpdaterPhase } from "../appUpdater";
|
||||
import { isAppUpdateBusy, type AppUpdaterPhase } from "../appUpdater";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
export type AppLanguage = "zh" | "en";
|
||||
@@ -102,11 +102,10 @@ export function AppShell({
|
||||
? (lang === "zh" ? "切换为浅色模式" : "Switch to light mode")
|
||||
: (lang === "zh" ? "切换为深色模式" : "Switch to dark mode");
|
||||
const ThemeIcon = theme === "dark" ? Moon : Sun;
|
||||
const updateActionState = updatePhase === "downloading"
|
||||
|| updatePhase === "installing"
|
||||
const updateActionState = isAppUpdateBusy(updatePhase)
|
||||
|| updatePhase === "ready"
|
||||
|| updatePhase === "available"
|
||||
? updatePhase
|
||||
? (isAppUpdateBusy(updatePhase) && updatePhase !== "downloading" ? "installing" : updatePhase)
|
||||
: hasUpdate
|
||||
? "available"
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useId } from "react";
|
||||
import { AlertTriangle, CheckCircle2, FileSearch, FolderOpen, Loader2, RefreshCw, Wrench } from "lucide-react";
|
||||
import type { ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
|
||||
export type ConfigHealthPanelProps = {
|
||||
lang: "zh" | "en";
|
||||
report: ConfigHealthReport | null;
|
||||
checking: boolean;
|
||||
repairing: boolean;
|
||||
error: string;
|
||||
onCheck: () => void;
|
||||
onRepair: () => void;
|
||||
onOpenConfig?: () => void;
|
||||
};
|
||||
|
||||
export function ConfigHealthStatus({ lang, report, checking, repairing, error }: Pick<ConfigHealthPanelProps, "lang" | "report" | "checking" | "repairing" | "error">) {
|
||||
const zh = lang === "zh";
|
||||
const busy = checking || repairing;
|
||||
const hasIssues = report?.status === "issues";
|
||||
const healthy = report?.status === "healthy" && !error;
|
||||
const tone = error || hasIssues || report?.status === "unavailable" ? "warning" : healthy ? "success" : "neutral";
|
||||
const status = repairing ? (zh ? "正在修复" : "Repairing")
|
||||
: checking ? (zh ? "正在检查" : "Checking")
|
||||
: error ? (zh ? "操作未完成" : "Action incomplete")
|
||||
: healthy ? (zh ? "配置正常" : "Looks good")
|
||||
: hasIssues ? (zh ? "发现问题" : "Needs attention")
|
||||
: report?.status === "missing" ? (zh ? "尚无配置" : "No configuration")
|
||||
: report?.status === "unavailable" ? (zh ? "暂时无法检查" : "Unavailable")
|
||||
: (zh ? "未检查" : "Not checked");
|
||||
const StatusIcon = busy ? Loader2 : tone === "warning" ? AlertTriangle : healthy ? CheckCircle2 : FileSearch;
|
||||
return <span className={`cx-config-health-status cx-config-health-status--${tone}`} role="status" aria-live="polite">
|
||||
<StatusIcon size={14} aria-hidden="true" className={busy ? "cx-config-health-spinner" : undefined} />
|
||||
{status}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function ConfigHealthPanel({ lang, report, checking, repairing, error, onCheck, onRepair, onOpenConfig }: ConfigHealthPanelProps) {
|
||||
const titleId = useId();
|
||||
const zh = lang === "zh";
|
||||
const busy = checking || repairing;
|
||||
const hasIssues = report?.status === "issues";
|
||||
const healthy = report?.status === "healthy" && !error;
|
||||
const manualIssues = hasIssues && report.issues.some((issue) => !issue.repairable);
|
||||
|
||||
return <section className="cx-config-health" aria-labelledby={titleId}>
|
||||
<div className="cx-config-health-heading">
|
||||
<span className="cx-config-health-icon" aria-hidden="true"><FileSearch size={19} /></span>
|
||||
<div className="cx-config-health-heading-copy">
|
||||
<h3 id={titleId}>{zh ? "配置检查与修复" : "Configuration check & repair"}</h3>
|
||||
<p>{zh ? "检查影响 Codex 启动和供应商使用的配置问题,由你决定是否修复。" : "Check for configuration issues that affect Codex and your providers. You choose whether to repair them."}</p>
|
||||
</div>
|
||||
<ConfigHealthStatus lang={lang} report={report} checking={checking} repairing={repairing} error={error} />
|
||||
</div>
|
||||
|
||||
{error && <p className="cx-config-health-error" role="alert">{error}</p>}
|
||||
|
||||
{hasIssues && <div className="cx-config-health-details">
|
||||
<ul className="cx-config-health-issues">
|
||||
{report.issues.map((issue, index) => <li key={`${issue.code}-${index}`}>
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
<div><strong>{issue.title}</strong><p>{issue.description}</p></div>
|
||||
</li>)}
|
||||
</ul>
|
||||
{report.canRepair && report.repairSummary.length > 0 && <div className="cx-config-health-plan">
|
||||
<strong>{zh ? "点击修复后,将进行以下调整:" : "Repair will make these changes:"}</strong>
|
||||
<ul>{report.repairSummary.map((summary, index) => <li key={index}>{summary}</li>)}</ul>
|
||||
</div>}
|
||||
{manualIssues && <p className="cx-config-health-note">{zh ? "部分问题需要手动检查。为保留你的配置,软件不会猜测或覆盖无法确认的内容。" : "Some issues need a manual check. Settings that cannot be determined safely will be left for you to review."}</p>}
|
||||
</div>}
|
||||
|
||||
{report?.status === "missing" && <p className="cx-config-health-note">{zh ? "还没有找到配置文件。完成 Codex 登录或启用供应商后,可以再次检查。" : "No configuration file was found. Check again after signing in to Codex or enabling a provider."}</p>}
|
||||
{report?.status === "unavailable" && !error && <p className="cx-config-health-note">{report.issues[0]?.description || (zh ? "暂时无法读取配置,请稍后重试或打开配置查看。" : "The configuration could not be read. Try again later or open it to review.")}</p>}
|
||||
|
||||
<div className="cx-config-health-footer">
|
||||
<p className="cx-config-health-note">{healthy
|
||||
? (zh ? "暂未发现需要修复的配置问题。" : "No configuration issues were found.")
|
||||
: (zh ? "检查不会修改配置。点击修复后,会先自动备份。" : "Checking does not change your settings. Choosing repair creates a backup first.")}</p>
|
||||
<div className="cx-config-health-actions">
|
||||
{onOpenConfig && (hasIssues || report?.status === "unavailable") && <Button variant="ghost" size="sm" icon={<FolderOpen size={15} />} disabled={busy} onClick={onOpenConfig}>{zh ? "查看配置" : "View configuration"}</Button>}
|
||||
<Button variant="secondary" size="sm" icon={checking ? <Loader2 size={15} className="cx-config-health-spinner" /> : <RefreshCw size={15} />} disabled={busy} onClick={onCheck}>{checking ? (zh ? "检查中" : "Checking") : (zh ? "检查配置" : "Check configuration")}</Button>
|
||||
{report?.canRepair && hasIssues && <Button size="sm" icon={repairing ? <Loader2 size={15} className="cx-config-health-spinner" /> : <Wrench size={15} />} disabled={busy} onClick={onRepair}>{repairing ? (zh ? "修复中" : "Repairing") : (zh ? "修复配置" : "Repair configuration")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AlertTriangle, ArrowRight, Loader2, Wrench, X } from "lucide-react";
|
||||
import type { ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
|
||||
export type ConfigHealthToastProps = {
|
||||
lang: "zh" | "en";
|
||||
report: ConfigHealthReport;
|
||||
repairing: boolean;
|
||||
onRepair: () => void;
|
||||
onDismiss: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
/** The caller owns notification lifetime; CSS never dismisses this toast. */
|
||||
export function ConfigHealthToast({ lang, report, repairing, onRepair, onDismiss, onOpenSettings }: ConfigHealthToastProps) {
|
||||
const zh = lang === "zh";
|
||||
return <aside className="cx-config-health-toast" aria-label={zh ? "配置检查提醒" : "Configuration check notification"}>
|
||||
<span className="cx-config-health-toast-icon" aria-hidden="true"><AlertTriangle size={20} /></span>
|
||||
<div className="cx-config-health-toast-content">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<strong>{repairing ? (zh ? "正在修复 Codex 配置" : "Repairing Codex configuration") : (zh ? "检测到 Codex 配置问题" : "Codex configuration needs attention")}</strong>
|
||||
<p>{(report.canRepair ? report.issues[0]?.title : report.issues[0]?.description) || (zh ? "部分配置可能影响 Codex 正常使用。" : "Some settings may prevent Codex from working correctly.")}</p>
|
||||
{report.canRepair && report.repairSummary[0] && <p className="cx-config-health-toast-plan">{report.repairSummary[0]}</p>}
|
||||
</div>
|
||||
<div className="cx-config-health-toast-actions">
|
||||
{report.canRepair && <Button size="sm" icon={repairing ? <Loader2 size={15} className="cx-config-health-spinner" /> : <Wrench size={15} />} disabled={repairing} onClick={onRepair}>{repairing ? (zh ? "修复中" : "Repairing") : (zh ? "修复配置" : "Repair configuration")}</Button>}
|
||||
<Button variant={report.canRepair ? "ghost" : "primary"} size="sm" icon={<ArrowRight size={15} />} iconPosition="end" disabled={repairing} onClick={onOpenSettings}>{zh ? "查看设置" : "View settings"}</Button>
|
||||
</div>
|
||||
<p className="cx-config-health-toast-hint">{zh ? "也可稍后前往「设置 → 通用设置 → 环境与配置检查」处理。" : "You can also use Settings → General → Environment & configuration check later."}</p>
|
||||
</div>
|
||||
<button type="button" className="cx-config-health-toast-close" disabled={repairing} onClick={onDismiss} aria-label={zh ? "关闭配置检查提醒" : "Dismiss configuration notification"} title={zh ? "关闭提醒" : "Dismiss"}><X size={17} aria-hidden="true" /></button>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CheckCircle2, Crown, Gem, KeyRound, Mail, ShieldCheck, Sparkles, UserRound } from "lucide-react";
|
||||
import { getOfficialPlan } from "../officialPlan";
|
||||
import "../styles/official-account-badge.css";
|
||||
|
||||
export type OfficialAccountBadgeProps = {
|
||||
lang: "zh" | "en";
|
||||
email?: string | null;
|
||||
hasAuth: boolean;
|
||||
canQueryQuota: boolean;
|
||||
planType?: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function OfficialAccountBadge({ lang, email, hasAuth, canQueryQuota, planType, className = "" }: OfficialAccountBadgeProps) {
|
||||
const signedOut = !hasAuth;
|
||||
const label = signedOut
|
||||
? (lang === "zh" ? "未登录" : "Not signed in")
|
||||
: !canQueryQuota
|
||||
? (lang === "zh" ? "已保存认证" : "Saved credentials")
|
||||
: email || (lang === "zh" ? "已登录" : "Signed in");
|
||||
const Icon = signedOut ? UserRound : !canQueryQuota ? KeyRound : email ? Mail : CheckCircle2;
|
||||
const state = signedOut ? "signed-out" : canQueryQuota ? "signed-in" : "saved";
|
||||
const plan = hasAuth && canQueryQuota ? getOfficialPlan(planType, lang) : null;
|
||||
const PlanIcon = plan?.tone === "pro20" ? Crown : plan?.tone === "pro5" ? Gem : plan?.tone === "enterprise" ? ShieldCheck : Sparkles;
|
||||
|
||||
return <span className={`cx-official-account ${className}`.trim()}>
|
||||
<span className={`cx-official-account-badge cx-official-account-badge--${state}`} title={label}>
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{plan && <span className={`cx-official-plan cx-official-plan--${plan.tone}`} title={`${lang === "zh" ? "套餐" : "Plan"}:${plan.label}`}>
|
||||
{plan.tone !== "free" && plan.tone !== "neutral" && <PlanIcon size={12} aria-hidden="true" />}
|
||||
<span>{plan.label}</span>
|
||||
</span>}
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AlertCircle, CheckCircle2, ChevronDown, Clock3, Gauge, Loader2, RefreshCw, Sparkles, Ticket } from "lucide-react";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
import { OfficialAccountBadge } from "./OfficialAccountBadge";
|
||||
import type { OfficialQuotaLimit, OfficialQuotaSnapshot, OfficialQuotaWindow, OfficialResetCreditsSnapshot } from "../officialQuotaTypes";
|
||||
import { initialOfficialQuotaState, loadOfficialQuotaDetails, officialQuotaReducer } from "../officialQuotaState";
|
||||
import { getOfficialPlan } from "../officialPlan";
|
||||
import "../styles/official-quota.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
type QuotaProfile = { id: string; name: string; email: string | null; planType?: string | null };
|
||||
|
||||
export type OfficialQuotaDialogProps = {
|
||||
lang: Language;
|
||||
configDir: string;
|
||||
profile: QuotaProfile | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function getCopy(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "官方 Codex 额度", close: "关闭", refresh: "刷新额度", refreshing: "查询中",
|
||||
loading: "正在查询此账号的可用额度…", loadingHint: "可以随时关闭窗口,稍后重新查询。",
|
||||
error: "暂时无法查询额度", previous: "刷新失败,当前保留上次查询结果。",
|
||||
accountUnknown: "未提供账号邮箱", planUnknown: "未提供套餐信息", remaining: "剩余额度", used: "已使用",
|
||||
resetUnknown: "未提供重置时间", resetDue: "已到重置时间,请刷新", percentUnknown: "未提供百分比",
|
||||
windowUnknown: "未知窗口", weekly: "每周额度", main: "通用额度", others: "其他额度",
|
||||
empty: "暂未返回通用额度", emptyHint: "服务未提供此账号的通用额度数据,可稍后刷新查询。",
|
||||
noWindow: "未提供额度窗口", noWindowHint: "服务未提供此项额度的百分比或重置时间。",
|
||||
unavailable: "当前不可用", reached: "额度已用完", available: "可用", checked: "查询于",
|
||||
codeReview: "代码审查", mismatch: "返回的额度不属于当前账号,请重新查询。",
|
||||
snapshotHint: "额度以最近一次查询结果为准。倒计时结束后请刷新确认。",
|
||||
resets: "可用重置", resetLoading: "查询中", resetError: "暂时无法查询重置次数",
|
||||
resetPrevious: "重置次数刷新失败,保留上次查询结果。", resetInvalid: "服务未返回有效的可用重置次数。",
|
||||
} : {
|
||||
title: "Official Codex quota", close: "Close", refresh: "Refresh quota", refreshing: "Checking",
|
||||
loading: "Checking this account’s available quota…", loadingHint: "You can close this window and check again later.",
|
||||
error: "Unable to check quota", previous: "Refresh failed. The last available results are shown.",
|
||||
accountUnknown: "Account email not provided", planUnknown: "Plan not provided", remaining: "Remaining", used: "Used",
|
||||
resetUnknown: "Reset time not provided", resetDue: "Reset time reached. Refresh to check.", percentUnknown: "Percentage not provided",
|
||||
windowUnknown: "Unknown window", weekly: "Weekly quota", main: "General quota", others: "Other quotas",
|
||||
empty: "General quota not returned", emptyHint: "The service did not provide this account’s general quota. Try refreshing later.",
|
||||
noWindow: "Quota window not provided", noWindowHint: "The service did not provide percentages or reset times for this quota.",
|
||||
unavailable: "Currently unavailable", reached: "Limit reached", available: "Available", checked: "Checked",
|
||||
codeReview: "Code review", mismatch: "The returned quota belongs to a different account. Please try again.",
|
||||
snapshotHint: "Quota reflects the last query. Refresh after the countdown ends to confirm availability.",
|
||||
resets: "Available resets", resetLoading: "Checking", resetError: "Unable to check available resets",
|
||||
resetPrevious: "Reset count refresh failed. The last result is shown.", resetInvalid: "The service did not return a valid available reset count.",
|
||||
};
|
||||
}
|
||||
|
||||
type QuotaCopy = ReturnType<typeof getCopy>;
|
||||
|
||||
function finitePercent(value: number | null): number | null {
|
||||
return value !== null && Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : null;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return Number(value.toFixed(1)).toString();
|
||||
}
|
||||
|
||||
function formatWindow(seconds: number | null, lang: Language, copy: QuotaCopy): string {
|
||||
if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) return copy.windowUnknown;
|
||||
if (seconds === 604_800) return copy.weekly;
|
||||
let rest = Math.floor(seconds);
|
||||
const values: string[] = [];
|
||||
const units: [number, string, string][] = [[86_400, "天", "d"], [3_600, "小时", "h"], [60, "分钟", "m"], [1, "秒", "s"]];
|
||||
for (const [size, zh, en] of units) {
|
||||
const value = Math.floor(rest / size);
|
||||
if (value) values.push(lang === "zh" ? `${value} ${zh}` : `${value}${en}`);
|
||||
rest %= size;
|
||||
}
|
||||
const duration = values.join(" ");
|
||||
return lang === "zh" ? `${duration}额度` : `${duration} quota`;
|
||||
}
|
||||
|
||||
function formatReset(resetsAt: string | null, now: number, lang: Language, copy: QuotaCopy): { relative: string; exact: string | null; overdue: boolean } {
|
||||
const time = resetsAt ? Date.parse(resetsAt) : Number.NaN;
|
||||
if (!Number.isFinite(time)) return { relative: copy.resetUnknown, exact: null, overdue: false };
|
||||
const exact = new Date(time).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", {
|
||||
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false,
|
||||
});
|
||||
if (time <= now) return { relative: copy.resetDue, exact, overdue: true };
|
||||
const minutes = Math.ceil((time - now) / 60_000);
|
||||
const days = Math.floor(minutes / 1_440);
|
||||
const hours = Math.floor(minutes % 1_440 / 60);
|
||||
const remainder = minutes % 60;
|
||||
let duration: string;
|
||||
if (lang === "zh") {
|
||||
duration = days ? `${days} 天${hours ? ` ${hours} 小时` : ""}` : hours ? `${hours} 小时${remainder ? ` ${remainder} 分钟` : ""}` : `${minutes} 分钟`;
|
||||
return { relative: `${duration}后重置`, exact, overdue: false };
|
||||
}
|
||||
duration = days ? `${days}d${hours ? ` ${hours}h` : ""}` : hours ? `${hours}h${remainder ? ` ${remainder}m` : ""}` : `${minutes}m`;
|
||||
return { relative: `Resets in ${duration}`, exact, overdue: false };
|
||||
}
|
||||
|
||||
function limitLabel(limit: OfficialQuotaLimit, copy: QuotaCopy): string {
|
||||
if (limit.id === "main") return copy.main;
|
||||
if (limit.id === "code_review" || limit.id === "code-review") return copy.codeReview;
|
||||
return limit.name?.trim() || limit.id;
|
||||
}
|
||||
|
||||
function QuotaWindow({ window, lang, copy, now, compact = false }: { window: OfficialQuotaWindow; lang: Language; copy: QuotaCopy; now: number; compact?: boolean }) {
|
||||
const remaining = finitePercent(window.remainingPercent);
|
||||
const used = finitePercent(window.usedPercent);
|
||||
const reset = formatReset(window.resetsAt, now, lang, copy);
|
||||
const label = formatWindow(window.windowSeconds, lang, copy);
|
||||
const low = remaining !== null && remaining <= 10;
|
||||
|
||||
return <article className={`cx-quota-window${compact ? " cx-quota-window--compact" : ""}${low ? " cx-quota-window--low" : ""}`}>
|
||||
<div className="cx-quota-window-summary">
|
||||
<div className="cx-quota-window-heading"><strong>{label}</strong></div>
|
||||
<div className="cx-quota-window-value"><span>{remaining === null ? "—" : formatPercent(remaining)}</span>{remaining !== null && <small>%</small>}</div>
|
||||
</div>
|
||||
<div className="cx-quota-window-progress">
|
||||
<span>{copy.remaining}</span>
|
||||
<div className={`cx-quota-meter${remaining === null ? " cx-quota-meter--unknown" : ""}`} role="progressbar" aria-label={`${label} · ${copy.remaining}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={remaining ?? undefined} aria-valuetext={remaining === null ? copy.percentUnknown : `${formatPercent(remaining)}%`}>
|
||||
{remaining !== null && <span style={{ width: `${remaining}%` }} />}
|
||||
</div>
|
||||
<div className="cx-quota-window-used" title={used === null ? copy.percentUnknown : undefined}>{copy.used} {used === null ? "—" : `${formatPercent(used)}%`}</div>
|
||||
</div>
|
||||
<div className={`cx-quota-reset${reset.overdue ? " cx-quota-reset--due" : ""}`}>
|
||||
<Clock3 size={12} aria-hidden="true" /><div><span>{reset.relative}</span>{reset.exact && <time dateTime={window.resetsAt!}>{reset.exact}</time>}</div>
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function QuotaLimit({ limit, lang, copy, now, compact = false }: { limit: OfficialQuotaLimit; lang: Language; copy: QuotaCopy; now: number; compact?: boolean }) {
|
||||
const status = limit.limitReached === true ? copy.reached : limit.allowed === false ? copy.unavailable : limit.allowed === true ? copy.available : null;
|
||||
const windowOrder = (window: OfficialQuotaWindow) => window.windowSeconds !== null && Number.isFinite(window.windowSeconds) && window.windowSeconds > 0 ? window.windowSeconds : Number.POSITIVE_INFINITY;
|
||||
const windows = [...limit.windows].sort((a, b) => windowOrder(a) - windowOrder(b));
|
||||
return <section className={`cx-quota-limit${compact ? " cx-quota-limit--compact" : ""}`}>
|
||||
<div className="cx-quota-limit-heading"><h3>{limitLabel(limit, copy)}</h3>{status && <span className={`cx-quota-status${limit.limitReached === true || limit.allowed === false ? " cx-quota-status--unavailable" : ""}`}>{limit.allowed === true && limit.limitReached !== true && <CheckCircle2 size={11} aria-hidden="true" />}{status}</span>}</div>
|
||||
{windows.length > 0 ? <div className="cx-quota-windows">{windows.map((window) => <QuotaWindow key={window.id} window={window} lang={lang} copy={copy} now={now} compact={compact} />)}</div> : <div className="cx-quota-no-window"><strong>{copy.noWindow}</strong><p>{copy.noWindowHint}</p></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function OfficialQuotaDialog({ lang, configDir, profile, onClose }: OfficialQuotaDialogProps) {
|
||||
const copy = getCopy(lang);
|
||||
const profileId = profile?.id ?? null;
|
||||
const queryKey = JSON.stringify([configDir, profileId]);
|
||||
const [state, dispatch] = useReducer(officialQuotaReducer, initialOfficialQuotaState);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const requestId = useRef(0);
|
||||
const currentKey = useRef(queryKey);
|
||||
currentKey.current = queryKey;
|
||||
const current = state.key === queryKey ? state : initialOfficialQuotaState;
|
||||
const { data, busy: quotaBusy, error } = current.quota;
|
||||
const { data: resetCredits, busy: resetBusy, error: resetError } = current.resetCredits;
|
||||
const busy = quotaBusy || resetBusy;
|
||||
|
||||
const load = useCallback(async (retainResult = false) => {
|
||||
if (!profileId) return;
|
||||
const request = ++requestId.current;
|
||||
dispatch({ type: "start", key: queryKey, requestId: request, retainResult });
|
||||
await loadOfficialQuotaDetails({
|
||||
key: queryKey, requestId: request, profileId,
|
||||
loadQuota: () => invoke<OfficialQuotaSnapshot>("get_official_profile_quota", { configDir: configDir || null, profileId }),
|
||||
loadResetCredits: () => invoke<OfficialResetCreditsSnapshot>("get_official_profile_reset_credits", { configDir: configDir || null, profileId }),
|
||||
dispatch,
|
||||
isCurrent: () => request === requestId.current && currentKey.current === queryKey,
|
||||
mismatchMessage: copy.mismatch, invalidCountMessage: copy.resetInvalid,
|
||||
});
|
||||
}, [configDir, profileId, queryKey, copy.mismatch, copy.resetInvalid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileId) { dispatch({ type: "clear", requestId: ++requestId.current }); return; }
|
||||
void load();
|
||||
return () => { requestId.current += 1; };
|
||||
}, [profileId, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileId) return;
|
||||
setNow(Date.now());
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [profileId, configDir]);
|
||||
|
||||
useEffect(() => { if (data) setNow(Date.now()); }, [data]);
|
||||
|
||||
const close = () => {
|
||||
dispatch({ type: "clear", requestId: ++requestId.current });
|
||||
onClose();
|
||||
};
|
||||
const main = data?.limits.find((limit) => limit.id === "main");
|
||||
const others = data?.limits.filter((limit) => limit.id !== "main") ?? [];
|
||||
const email = data?.email || profile?.email;
|
||||
const checked = data?.checkedAt && Number.isFinite(Date.parse(data.checkedAt)) ? new Date(data.checkedAt).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) : null;
|
||||
|
||||
return <ModalShell open={Boolean(profile)} onClose={close} title={copy.title} description={profile?.name} closeLabel={copy.close} size="md" className="cx-official-quota-dialog" bodyClassName="cx-official-quota-body" footer={
|
||||
<><Button variant="secondary" onClick={close}>{copy.close}</Button><Button variant="primary" icon={busy ? <Loader2 size={14} className="cx-quota-spin" /> : <RefreshCw size={14} />} disabled={busy} onClick={() => void load(true)}>{busy ? copy.refreshing : copy.refresh}</Button></>
|
||||
}>
|
||||
<div className="cx-quota-account"><div className="cx-quota-account-icon" aria-hidden="true"><Gauge size={21} strokeWidth={1.7} /></div><div className="cx-quota-account-copy"><div><Sparkles size={12} aria-hidden="true" /><strong>{getOfficialPlan(data?.planType ?? profile?.planType, lang)?.label || copy.planUnknown}</strong></div><OfficialAccountBadge lang={lang} email={email} hasAuth canQueryQuota className="cx-quota-account-email" /></div></div>
|
||||
<section className={`cx-quota-reset-credits${resetError ? " cx-quota-reset-credits--error" : ""}`} aria-label={copy.resets} aria-busy={resetBusy}>
|
||||
<div className="cx-quota-reset-credits-row">
|
||||
<span><Ticket size={16} aria-hidden="true" />{copy.resets}</span>
|
||||
<div aria-live="polite">
|
||||
{resetBusy && <Loader2 size={13} className="cx-quota-spin" aria-hidden="true" />}
|
||||
{resetCredits ? <strong>{resetCredits.availableCount}<small>{lang === "zh" ? "次" : resetCredits.availableCount === 1 ? "reset" : "resets"}</small></strong> : <span className="cx-quota-reset-credits-status">{resetBusy ? copy.resetLoading : "—"}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{resetError && <p role="alert"><AlertCircle size={12} aria-hidden="true" /><span><strong>{resetCredits ? copy.resetPrevious : copy.resetError}</strong><span>{resetError}</span></span></p>}
|
||||
</section>
|
||||
{error && <div className="cx-quota-error" role="alert"><AlertCircle size={17} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.previous}</p>}<p>{error}</p></div></div>}
|
||||
{quotaBusy && !data && <div className="cx-quota-loading" role="status"><Loader2 size={24} className="cx-quota-spin" aria-hidden="true" /><strong>{copy.loading}</strong><p>{copy.loadingHint}</p><div className="cx-quota-loading-bars" aria-hidden="true"><span /><span /></div></div>}
|
||||
{data && <>
|
||||
{main ? <QuotaLimit limit={main} lang={lang} copy={copy} now={now} /> : <div className="cx-quota-empty"><Gauge size={26} aria-hidden="true" /><strong>{copy.empty}</strong><p>{copy.emptyHint}</p></div>}
|
||||
{others.length > 0 && <details className="cx-quota-other"><summary><span>{copy.others}<small>{others.length}</small></span><ChevronDown size={15} aria-hidden="true" /></summary><div>{others.map((limit) => <QuotaLimit key={limit.id} limit={limit} lang={lang} copy={copy} now={now} compact />)}</div></details>}
|
||||
<div className="cx-quota-footnote">{checked && <span><CheckCircle2 size={11} aria-hidden="true" />{copy.checked} {checked}</span>}<p>{copy.snapshotHint}</p></div>
|
||||
</>}
|
||||
</ModalShell>;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, ListFilter, Plus, Trash2 } from "lucide-react";
|
||||
import type { ProviderModelMapping } from "../types";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
type MappingError = { model?: string; contextWindow?: string };
|
||||
const MAX_MAPPINGS = 64;
|
||||
const MAX_CONTEXT_WINDOW = 10_000_000;
|
||||
|
||||
function getCopy(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "模型映射", optional: "可选", subtitle: "让 Codex 的模型菜单显示这个供应商实际提供的模型。",
|
||||
hint: "适用于支持 Responses 接口的模型。保存并启用后,重启 Codex 更新模型菜单。已有会话会保留原来的模型选择。",
|
||||
capabilities: "模型菜单允许文本与图片输入;实际能力由供应商接口决定。Ultra 还需在 Codex 设置中开启「模型选择器滑块中的 Ultra 选项」。",
|
||||
displayName: "菜单显示名称", displayPlaceholder: "留空使用模型 ID", model: "实际模型 ID", modelPlaceholder: "填写供应商提供的模型 ID",
|
||||
context: "上下文窗口", contextPlaceholder: "可选,如 128000", contextHint: "单位为 Token;留空使用默认值,最大 10,000,000。",
|
||||
addCurrent: "添加当前模型", importModels: "导入已获取模型", addRow: "添加空行", remove: "删除映射",
|
||||
empty: "暂未设置模型映射", emptyHint: "留空表示不设置自定义模型菜单。可以添加当前模型,或先获取供应商的模型列表再导入。",
|
||||
noCurrent: "请先填写上方的模型", currentExists: "当前模型已添加", fetchFirst: "请先在上方获取模型列表", allImported: "已获取的模型均已添加",
|
||||
limit: "模型菜单最多支持 64 个模型(包含当前默认模型)。", reserved: "当前默认模型会自动保留在菜单中,并计入 64 个模型的上限。",
|
||||
missingModel: "请填写实际模型 ID", invalidModel: "模型 ID 不能包含控制字符", longModel: "模型 ID 最多 200 个字符",
|
||||
invalidContext: "请填写 1 至 10,000,000 的整数,或留空。", fixErrors: "请先修正标红的内容,再保存供应商。",
|
||||
rowLabel: (index: number) => `第 ${index + 1} 行`, imported: (count: number) => `已添加 ${count} 个模型。`,
|
||||
importLimited: (added: number, skipped: number) => `已添加 ${added} 个模型,达到 64 个上限,另有 ${skipped} 个未添加。`,
|
||||
invalidDefault: "上方的当前模型 ID 不符合要求,请先修改(最多 200 个字符,不能包含控制字符)。",
|
||||
} : {
|
||||
title: "Model mappings", optional: "Optional", subtitle: "Show this provider’s actual models in the Codex model menu.",
|
||||
hint: "For models that support the Responses API. Save and enable this provider, then restart Codex to update its model menu. Existing conversations keep their selected model.",
|
||||
capabilities: "The menu allows text and image input; actual support depends on the provider API. To show Ultra, also enable the Ultra option in the model picker slider in Codex settings.",
|
||||
displayName: "Menu display name", displayPlaceholder: "Defaults to the model ID", model: "Actual model ID", modelPlaceholder: "Enter the provider’s model ID",
|
||||
context: "Context window", contextPlaceholder: "Optional, e.g. 128000", contextHint: "In tokens. Leave blank for the default; maximum 10,000,000.",
|
||||
addCurrent: "Add current model", importModels: "Import fetched models", addRow: "Add row", remove: "Remove mapping",
|
||||
empty: "No model mappings yet", emptyHint: "Leave this empty to avoid setting a custom model menu. Add the current model, or fetch the provider’s models above and import them.",
|
||||
noCurrent: "Enter a model above first", currentExists: "The current model is already included", fetchFirst: "Fetch the model list above first", allImported: "All fetched models are already included",
|
||||
limit: "The model menu supports up to 64 models, including the current default model.", reserved: "The current default model is kept in the menu automatically and counts toward the 64-model limit.",
|
||||
missingModel: "Enter the actual model ID", invalidModel: "Model IDs cannot contain control characters", longModel: "Model IDs cannot exceed 200 characters",
|
||||
invalidContext: "Enter a whole number from 1 to 10,000,000, or leave blank.", fixErrors: "Correct the highlighted fields before saving this provider.",
|
||||
rowLabel: (index: number) => `Row ${index + 1}`, imported: (count: number) => `Added ${count} model(s).`,
|
||||
importLimited: (added: number, skipped: number) => `Added ${added} model(s). The 64-model limit was reached; ${skipped} were not added.`,
|
||||
invalidDefault: "Correct the current model ID above first: at most 200 characters, without control characters.",
|
||||
};
|
||||
}
|
||||
|
||||
function hasInvalidModelId(value: string): boolean {
|
||||
return Array.from(value).length > 200 || /[\u0000-\u001f\u007f-\u009f]/.test(value);
|
||||
}
|
||||
|
||||
function menuSize(rows: readonly ProviderModelMapping[], currentModel: string): number {
|
||||
const current = currentModel.trim();
|
||||
return rows.length + (rows.length > 0 && current && !rows.some((row) => row.model.trim() === current) ? 1 : 0);
|
||||
}
|
||||
|
||||
export function validateProviderModelMappings(rows: readonly ProviderModelMapping[], currentModel: string, lang: Language) {
|
||||
const copy = getCopy(lang);
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const id = row.model.trim();
|
||||
if (id) counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
const errors: MappingError[] = rows.map((row) => {
|
||||
const id = row.model.trim();
|
||||
const error: MappingError = {};
|
||||
if (!id) error.model = copy.missingModel;
|
||||
else if (/[\u0000-\u001f\u007f-\u009f]/.test(id)) error.model = copy.invalidModel;
|
||||
else if (Array.from(id).length > 200) error.model = copy.longModel;
|
||||
else if ((counts.get(id) ?? 0) > 1) error.model = lang === "zh" ? "此模型 ID 已添加,请删除重复行。" : "This model ID is already included. Remove the duplicate row.";
|
||||
if (row.contextWindow !== null && (!Number.isSafeInteger(row.contextWindow) || row.contextWindow <= 0 || row.contextWindow > MAX_CONTEXT_WINDOW)) error.contextWindow = copy.invalidContext;
|
||||
return error;
|
||||
});
|
||||
const size = menuSize(rows, currentModel);
|
||||
const defaultInvalid = rows.length > 0 && hasInvalidModelId(currentModel.trim());
|
||||
return { errors, size, valid: size <= MAX_MAPPINGS && !defaultInvalid && errors.every((error) => !error.model && !error.contextWindow), defaultInvalid };
|
||||
}
|
||||
|
||||
export function ProviderModelMappings({ lang, rows, currentModel, availableModels, disabled, onChange }: {
|
||||
lang: Language;
|
||||
rows: readonly ProviderModelMapping[];
|
||||
currentModel: string;
|
||||
availableModels: readonly string[];
|
||||
disabled: boolean;
|
||||
onChange: (rows: ProviderModelMapping[]) => void;
|
||||
}) {
|
||||
const copy = getCopy(lang);
|
||||
const id = useId();
|
||||
const [expanded, setExpanded] = useState(rows.length > 0);
|
||||
const [notice, setNotice] = useState("");
|
||||
// Keep incomplete numeric input visible. Its parent value is the invalid
|
||||
// sentinel 0, so both the form and save validation reject it instead of
|
||||
// silently serializing it as an omitted optional value.
|
||||
const [contextDrafts, setContextDrafts] = useState<Record<number, { value: number | null; text: string }>>({});
|
||||
const validation = validateProviderModelMappings(rows, currentModel, lang);
|
||||
const current = currentModel.trim();
|
||||
const currentIncluded = Boolean(current) && rows.some((row) => row.model.trim() === current);
|
||||
const candidates = useMemo(() => {
|
||||
const existing = new Set(rows.map((row) => row.model.trim()));
|
||||
return Array.from(new Set(availableModels.map((model) => model.trim()).filter(Boolean))).filter((model) => !existing.has(model));
|
||||
}, [availableModels, rows]);
|
||||
const canAppend = (model: string) => menuSize([...rows, { model, displayName: model, contextWindow: null }], current) <= MAX_MAPPINGS;
|
||||
const canAddCurrent = Boolean(current) && !currentIncluded && !hasInvalidModelId(current) && canAppend(current);
|
||||
const canImport = candidates.some(canAppend);
|
||||
const reserved = rows.length > 0 && Boolean(current) && !currentIncluded;
|
||||
|
||||
useEffect(() => { if (rows.length > 0) setExpanded(true); }, [rows.length]);
|
||||
|
||||
const update = (index: number, patch: Partial<ProviderModelMapping>) => {
|
||||
setNotice("");
|
||||
onChange(rows.map((row, position) => position === index ? { ...row, ...patch } : row));
|
||||
};
|
||||
const add = (model: string) => {
|
||||
if (disabled || !canAppend(model)) return;
|
||||
setNotice("");
|
||||
setExpanded(true);
|
||||
onChange([...rows, { model, displayName: model, contextWindow: null }]);
|
||||
};
|
||||
const remove = (index: number) => {
|
||||
if (disabled) return;
|
||||
setContextDrafts((previous) => Object.fromEntries(Object.entries(previous).filter(([key]) => Number(key) !== index).map(([key, value]) => [Number(key) > index ? Number(key) - 1 : Number(key), value])));
|
||||
setNotice("");
|
||||
onChange(rows.filter((_, position) => position !== index));
|
||||
};
|
||||
const importModels = () => {
|
||||
if (disabled) return;
|
||||
let next = [...rows];
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const model of candidates) {
|
||||
const proposed = [...next, { model, displayName: model, contextWindow: null }];
|
||||
if (menuSize(proposed, current) > MAX_MAPPINGS) { skipped += 1; continue; }
|
||||
next = proposed;
|
||||
added += 1;
|
||||
}
|
||||
if (added) onChange(next);
|
||||
setExpanded(true);
|
||||
setNotice(skipped ? copy.importLimited(added, skipped) : copy.imported(added));
|
||||
};
|
||||
|
||||
return <details className="cx-provider-mappings" open={expanded} onToggle={(event) => setExpanded(event.currentTarget.open)}>
|
||||
<summary><ListFilter size={17} aria-hidden="true" /><div><strong>{copy.title}<span>{copy.optional}</span></strong><p>{copy.subtitle}</p></div><span className={`cx-provider-mappings-count${!validation.valid ? " cx-provider-mappings-count--invalid" : ""}`} title={copy.limit}>{validation.size} / {MAX_MAPPINGS}</span><ChevronDown size={16} className="cx-provider-mappings-chevron" aria-hidden="true" /></summary>
|
||||
<div className="cx-provider-mappings-body">
|
||||
<p className="cx-provider-mappings-hint">{copy.hint}</p>
|
||||
<p className="cx-provider-mappings-hint">{copy.capabilities}</p>
|
||||
<div className="cx-provider-mappings-toolbar">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add(current)} disabled={disabled || !canAddCurrent} title={currentIncluded ? copy.currentExists : !current ? copy.noCurrent : hasInvalidModelId(current) ? copy.invalidDefault : !canAddCurrent ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addCurrent}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={importModels} disabled={disabled || !canImport} title={!availableModels.length ? copy.fetchFirst : !candidates.length ? copy.allImported : !canImport ? copy.limit : undefined}><Download size={14} aria-hidden="true" />{copy.importModels}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add("")} disabled={disabled || !canAppend("")} title={!canAppend("") ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addRow}</button>
|
||||
</div>
|
||||
{notice && <p className="cx-provider-mappings-notice" role="status">{notice}</p>}
|
||||
{rows.length === 0 ? <div className="cx-provider-mappings-empty"><ListFilter size={22} aria-hidden="true" /><strong>{copy.empty}</strong><p>{copy.emptyHint}</p></div> : <>
|
||||
<div className="cx-provider-mappings-labels" aria-hidden="true"><span>{copy.displayName}</span><span>{copy.model}</span><span>{copy.context}</span><span /></div>
|
||||
<div className="cx-provider-mappings-rows">
|
||||
{rows.map((row, index) => {
|
||||
const error = validation.errors[index];
|
||||
const contextDraft = contextDrafts[index];
|
||||
const contextText = contextDraft && Object.is(contextDraft.value, row.contextWindow) ? contextDraft.text : row.contextWindow === null ? "" : String(row.contextWindow);
|
||||
return <div className="cx-provider-mapping-row" key={index} role="group" aria-label={copy.rowLabel(index)}>
|
||||
<label className="cx-provider-mapping-field"><span>{copy.displayName}</span><input value={row.displayName} placeholder={row.model.trim() || copy.displayPlaceholder} aria-label={`${copy.displayName} · ${copy.rowLabel(index)}`} onChange={(event) => update(index, { displayName: event.currentTarget.value })} disabled={disabled} /></label>
|
||||
<label className="cx-provider-mapping-field"><span>{copy.model}</span><input value={row.model} placeholder={copy.modelPlaceholder} aria-label={`${copy.model} · ${copy.rowLabel(index)}`} aria-invalid={Boolean(error.model)} aria-describedby={error.model ? `${id}-${index}-model-error` : undefined} onChange={(event) => update(index, { model: event.currentTarget.value })} disabled={disabled} spellCheck={false} />{error.model && <small id={`${id}-${index}-model-error`}>{error.model}</small>}</label>
|
||||
<label className="cx-provider-mapping-field"><span>{copy.context}</span><input value={contextText} inputMode="numeric" placeholder={copy.contextPlaceholder} aria-label={`${copy.context} · ${copy.rowLabel(index)}`} aria-invalid={Boolean(error.contextWindow)} aria-describedby={error.contextWindow ? `${id}-${index}-context-error` : `${id}-context-hint`} onChange={(event) => {
|
||||
const text = event.currentTarget.value;
|
||||
const trimmed = text.trim();
|
||||
const number = Number(trimmed);
|
||||
const value = !trimmed ? null : /^\d+$/.test(trimmed) && Number.isSafeInteger(number) && number > 0 && number <= MAX_CONTEXT_WINDOW ? number : 0;
|
||||
setContextDrafts((previous) => ({ ...previous, [index]: { text, value } }));
|
||||
update(index, { contextWindow: value });
|
||||
}} disabled={disabled} />{error.contextWindow && <small id={`${id}-${index}-context-error`}>{error.contextWindow}</small>}</label>
|
||||
<button type="button" className="cx-providers-icon-button cx-providers-icon-button--danger cx-provider-mapping-remove" onClick={() => remove(index)} disabled={disabled} title={`${copy.remove} · ${copy.rowLabel(index)}`} aria-label={`${copy.remove} · ${copy.rowLabel(index)}`}><Trash2 size={14} aria-hidden="true" /></button>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<p className="cx-provider-mappings-field-hint" id={`${id}-context-hint`}>{copy.contextHint}</p>
|
||||
{reserved && <p className="cx-provider-mappings-field-hint">{copy.reserved}</p>}
|
||||
{!validation.valid && <p className="cx-provider-mappings-error" role="status">{validation.size > MAX_MAPPINGS ? copy.limit : validation.defaultInvalid ? copy.invalidDefault : copy.fixErrors}</p>}
|
||||
</>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useId } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Check, SlidersHorizontal } from "lucide-react";
|
||||
import "../styles/provider-preset-picker.css";
|
||||
|
||||
export type ProviderPresetBrand = "custom" | "openai" | "deepseek" | "minimax" | "mimo" | "qwen" | "kimi" | "glm" | "doubao";
|
||||
|
||||
export type ProviderPresetChoice = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
brand: ProviderPresetBrand;
|
||||
subtitle?: string;
|
||||
subtitleEn?: string;
|
||||
};
|
||||
|
||||
function PresetMark({ brand }: { brand: ProviderPresetBrand }) {
|
||||
const logo = brand === "openai" ? "/openai.svg" : `/provider-logos/${brand}.svg`;
|
||||
return <span className={`cx-preset-mark cx-preset-mark--${brand}`} aria-hidden="true">
|
||||
{brand === "custom" ? <SlidersHorizontal size={19} strokeWidth={1.8} /> : brand === "doubao" ? "豆" : <span className="cx-preset-brand-logo" style={{ WebkitMaskImage: `url("${logo}")`, maskImage: `url("${logo}")` }} />}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function ProviderPresetPicker({ lang, presets, selectedId, disabled = false, onSelect, children }: {
|
||||
lang: "zh" | "en";
|
||||
presets: readonly ProviderPresetChoice[];
|
||||
selectedId: string;
|
||||
disabled?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const title = lang === "zh" ? "预设供应商" : "Provider presets";
|
||||
|
||||
return <section className="cx-preset-picker" aria-label={title}>
|
||||
<fieldset className="cx-preset-fieldset" disabled={disabled} aria-label={title}>
|
||||
<div className="cx-preset-grid">
|
||||
{presets.map((preset) => {
|
||||
const name = lang === "en" ? preset.nameEn ?? preset.name : preset.name;
|
||||
return <label className="cx-preset-option" key={preset.id}>
|
||||
<input className="cx-preset-radio" type="radio" name={`${id}-provider-preset`} value={preset.id} checked={preset.id === selectedId} onChange={() => onSelect(preset.id)} aria-label={name} />
|
||||
<span className="cx-preset-card">
|
||||
<PresetMark brand={preset.brand} />
|
||||
<span className="cx-preset-copy"><strong>{name}</strong></span>
|
||||
<span className="cx-preset-selected" aria-hidden="true"><Check size={13} strokeWidth={2.8} /></span>
|
||||
</span>
|
||||
</label>;
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
{children && <div className="cx-preset-details">{children}</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ConfigHealthReport } from "./configHealthTypes";
|
||||
|
||||
type Options = {
|
||||
read: () => Promise<ConfigHealthReport>;
|
||||
onReport: (report: ConfigHealthReport) => void;
|
||||
onProblem: (report: ConfigHealthReport) => void;
|
||||
onRecovered?: (report: ConfigHealthReport) => void;
|
||||
canRead: () => boolean;
|
||||
canNotify: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
revision: () => number;
|
||||
intervalMs?: number;
|
||||
settleMs?: number;
|
||||
now?: () => number;
|
||||
schedule?: (callback: () => void, delay: number) => () => void;
|
||||
};
|
||||
|
||||
// Confirm a broken file twice before notifying: an editor may briefly truncate
|
||||
// or replace it. Reads are single-flight and never change shared loading state.
|
||||
export function createConfigHealthMonitor(options: Options) {
|
||||
const schedule = options.schedule ?? ((callback, delay) => {
|
||||
const timer = setTimeout(callback, delay);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
const now = options.now ?? Date.now;
|
||||
const interval = options.intervalMs ?? 20_000;
|
||||
const settle = options.settleMs ?? 1_000;
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
let wakePending = false;
|
||||
let cancel: (() => void) | undefined;
|
||||
let candidate: { fingerprint: string; revision: number; at: number } | null = null;
|
||||
let recovery: { fingerprint: string; revision: number; at: number } | null = null;
|
||||
|
||||
function next(delay = interval) {
|
||||
cancel?.();
|
||||
cancel = undefined;
|
||||
if (!stopped && options.isVisible()) cancel = schedule(wake, delay);
|
||||
}
|
||||
async function read() {
|
||||
if (stopped || !options.isVisible()) return;
|
||||
if (!options.canRead()) { next(); return; }
|
||||
inFlight = true;
|
||||
const revision = options.revision();
|
||||
let delay = interval;
|
||||
try {
|
||||
const report = await options.read();
|
||||
if (stopped || wakePending || revision !== options.revision() || !options.canRead() || !options.isVisible()) return;
|
||||
options.onReport(report);
|
||||
if (report.status === "healthy") {
|
||||
if (recovery?.fingerprint === report.fingerprint && recovery.revision === revision && now() - recovery.at >= settle) {
|
||||
options.onRecovered?.(report);
|
||||
} else if (!recovery || recovery.fingerprint !== report.fingerprint || recovery.revision !== revision) {
|
||||
recovery = { fingerprint: report.fingerprint, revision, at: now() };
|
||||
}
|
||||
} else recovery = null;
|
||||
if (report.status !== "issues" || !report.issues.length) {
|
||||
candidate = null;
|
||||
return;
|
||||
}
|
||||
if (!candidate || candidate.fingerprint !== report.fingerprint || candidate.revision !== revision) {
|
||||
candidate = { fingerprint: report.fingerprint, revision, at: now() };
|
||||
delay = settle;
|
||||
} else if (now() - candidate.at < settle) {
|
||||
delay = settle - (now() - candidate.at);
|
||||
} else if (options.canNotify()) {
|
||||
options.onProblem(report);
|
||||
}
|
||||
} catch {
|
||||
// A transient read/IPC failure is not evidence of a configuration error.
|
||||
candidate = null;
|
||||
recovery = null;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (wakePending) { wakePending = false; wake(); } else next(delay);
|
||||
}
|
||||
}
|
||||
function wake() {
|
||||
cancel?.(); cancel = undefined;
|
||||
if (stopped) return;
|
||||
if (inFlight) { wakePending = true; return; }
|
||||
void read();
|
||||
}
|
||||
return { wake, stop() { stopped = true; cancel?.(); cancel = undefined; candidate = null; } };
|
||||
}
|
||||
|
||||
// This is only notification deduplication, not a security or repair token.
|
||||
function labelHash(value: string) {
|
||||
let first = 2166136261;
|
||||
let second = 5381;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
first = Math.imul(first ^ value.charCodeAt(index), 16777619);
|
||||
second = Math.imul(second, 33) ^ value.charCodeAt(index);
|
||||
}
|
||||
return `${(first >>> 0).toString(16)}-${(second >>> 0).toString(16)}`;
|
||||
}
|
||||
export function configHealthIssueKey(report: ConfigHealthReport) {
|
||||
return labelHash(JSON.stringify(report.issues.map(({ code, title, description }) => [code, title, description]).sort()));
|
||||
}
|
||||
type StorageLike = Pick<Storage, "getItem" | "setItem">;
|
||||
export function createConfigHealthNoticeRegistry(storage?: StorageLike) {
|
||||
const storageKey = "codexx.configHealth.notified.v1";
|
||||
let entries: { scope: string; issue: string }[] = [];
|
||||
try {
|
||||
const saved: unknown = JSON.parse(storage?.getItem(storageKey) || "[]");
|
||||
if (Array.isArray(saved)) entries = saved.filter((value) => typeof value?.scope === "string" && typeof value?.issue === "string").slice(-32);
|
||||
} catch { /* Use in-memory suppression if browser storage is unavailable. */ }
|
||||
const scope = (report: ConfigHealthReport) => labelHash(report.codexDir.replace(/\\/g, "/"));
|
||||
const issue = configHealthIssueKey;
|
||||
const persist = () => { try { storage?.setItem(storageKey, JSON.stringify(entries)); } catch { /* Optional persistence. */ } };
|
||||
return {
|
||||
has(report: ConfigHealthReport) { return entries.some((entry) => entry.scope === scope(report) && entry.issue === issue(report)); },
|
||||
mark(report: ConfigHealthReport) {
|
||||
entries = [...entries.filter((entry) => entry.scope !== scope(report) || entry.issue !== issue(report)), { scope: scope(report), issue: issue(report) }].slice(-32);
|
||||
persist();
|
||||
},
|
||||
clear(report: ConfigHealthReport) {
|
||||
const remaining = entries.filter((entry) => entry.scope !== scope(report));
|
||||
if (remaining.length !== entries.length) { entries = remaining; persist(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ConfigHealthIssue = {
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
repairable: boolean;
|
||||
};
|
||||
|
||||
export type ConfigHealthReport = {
|
||||
codexDir: string;
|
||||
fingerprint: string;
|
||||
status: "healthy" | "issues" | "missing" | "unavailable";
|
||||
issues: ConfigHealthIssue[];
|
||||
canRepair: boolean;
|
||||
repairSummary: string[];
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export type ConfigHealthRepairResult = {
|
||||
report: ConfigHealthReport;
|
||||
backupId: string | null;
|
||||
changed: boolean;
|
||||
};
|
||||
+475
-103
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
export type OfficialPlanTone = "pro20" | "pro5" | "plus" | "team" | "enterprise" | "edu" | "free" | "neutral";
|
||||
export type OfficialPlan = { label: string; tone: OfficialPlanTone };
|
||||
|
||||
// Display aliases requested for the account UI; unknown plans remain neutral.
|
||||
export function getOfficialPlan(planType: string | null | undefined, lang: "zh" | "en"): OfficialPlan | null {
|
||||
if (typeof planType !== "string" || planType.length > 64 || /[\u0000-\u001f\u007f-\u009f]/.test(planType)) return null;
|
||||
const raw = planType.trim();
|
||||
if (!raw) return null;
|
||||
const key = raw.toLowerCase().replace(/[_\s]+/g, "-");
|
||||
if (["pro", "pro20x", "pro-20x"].includes(key)) return { label: "Pro 20x", tone: "pro20" };
|
||||
if (["prolite", "pro-lite", "pro5x", "pro-5x"].includes(key)) return { label: "Pro 5x", tone: "pro5" };
|
||||
const known: Record<string, OfficialPlan> = {
|
||||
plus: { label: "Plus", tone: "plus" },
|
||||
team: { label: "Team", tone: "team" },
|
||||
business: { label: "Business", tone: "team" },
|
||||
enterprise: { label: "Enterprise", tone: "enterprise" },
|
||||
edu: { label: "Edu", tone: "edu" },
|
||||
free: { label: lang === "zh" ? "免费版" : "Free", tone: "free" },
|
||||
go: { label: "Go", tone: "neutral" },
|
||||
};
|
||||
return Object.prototype.hasOwnProperty.call(known, key) ? known[key] : { label: raw, tone: "neutral" };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { OfficialProfileSummary } from "./types";
|
||||
|
||||
type Options = {
|
||||
read: () => Promise<OfficialProfileSummary[]>;
|
||||
apply: (profiles: OfficialProfileSummary[]) => void;
|
||||
revision: () => number;
|
||||
canRead: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
intervalMs?: number;
|
||||
schedule?: (callback: () => void, delay: number) => () => void;
|
||||
};
|
||||
|
||||
// Only refreshes local display metadata. No full state reload, draft writes,
|
||||
// network quota requests, or shared loading indicators belong in this monitor.
|
||||
export function createOfficialProfileMonitor(options: Options) {
|
||||
const schedule = options.schedule ?? ((callback, delay) => {
|
||||
const timer = setTimeout(callback, delay);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
let wakePending = false;
|
||||
let cancelTimer: (() => void) | undefined;
|
||||
|
||||
function next() {
|
||||
cancelTimer?.();
|
||||
cancelTimer = undefined;
|
||||
if (!stopped && options.isVisible()) {
|
||||
cancelTimer = schedule(wake, options.intervalMs ?? 2_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function read() {
|
||||
if (stopped || !options.isVisible()) return;
|
||||
if (!options.canRead()) { next(); return; }
|
||||
inFlight = true;
|
||||
const revision = options.revision();
|
||||
try {
|
||||
const profiles = await options.read();
|
||||
if (!stopped && !wakePending && options.isVisible() && options.canRead()
|
||||
&& options.revision() === revision) {
|
||||
options.apply(profiles);
|
||||
}
|
||||
} catch {
|
||||
// A temporary file/IPC failure must not blank the list or interrupt work.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (wakePending) {
|
||||
wakePending = false;
|
||||
wake();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wake() {
|
||||
cancelTimer?.();
|
||||
cancelTimer = undefined;
|
||||
if (stopped) return;
|
||||
if (inFlight) { wakePending = true; return; }
|
||||
void read();
|
||||
}
|
||||
|
||||
return {
|
||||
wake,
|
||||
stop() {
|
||||
stopped = true;
|
||||
wakePending = false;
|
||||
cancelTimer?.();
|
||||
cancelTimer = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { OfficialQuotaSnapshot, OfficialResetCreditsSnapshot } from "./officialQuotaTypes";
|
||||
|
||||
type QueryResult<T> = { data: T | null; busy: boolean; error: string };
|
||||
export type OfficialQuotaState = {
|
||||
key: string | null;
|
||||
requestId: number;
|
||||
quota: QueryResult<OfficialQuotaSnapshot>;
|
||||
resetCredits: QueryResult<OfficialResetCreditsSnapshot>;
|
||||
};
|
||||
|
||||
const emptyResult = () => ({ data: null, busy: false, error: "" });
|
||||
export const initialOfficialQuotaState: OfficialQuotaState = {
|
||||
key: null, requestId: 0, quota: emptyResult(), resetCredits: emptyResult(),
|
||||
};
|
||||
|
||||
type RequestIdentity = { key: string; requestId: number };
|
||||
export type OfficialQuotaAction =
|
||||
| ({ type: "start"; retainResult: boolean } & RequestIdentity)
|
||||
| { type: "clear"; requestId: number }
|
||||
| ({ type: "quota-success"; data: OfficialQuotaSnapshot } & RequestIdentity)
|
||||
| ({ type: "reset-success"; data: OfficialResetCreditsSnapshot } & RequestIdentity)
|
||||
| ({ type: "quota-error" | "reset-error"; error: string } & RequestIdentity);
|
||||
|
||||
export function officialQuotaReducer(state: OfficialQuotaState, action: OfficialQuotaAction): OfficialQuotaState {
|
||||
if (action.type === "clear") {
|
||||
return action.requestId < state.requestId ? state : { ...initialOfficialQuotaState, requestId: action.requestId };
|
||||
}
|
||||
if (action.type === "start") {
|
||||
if (action.requestId < state.requestId) return state;
|
||||
const retain = action.retainResult && action.key === state.key;
|
||||
return {
|
||||
key: action.key, requestId: action.requestId,
|
||||
quota: { data: retain ? state.quota.data : null, busy: true, error: "" },
|
||||
resetCredits: { data: retain ? state.resetCredits.data : null, busy: true, error: "" },
|
||||
};
|
||||
}
|
||||
if (action.key !== state.key || action.requestId !== state.requestId) return state;
|
||||
if (action.type === "quota-success") return { ...state, quota: { data: action.data, busy: false, error: "" } };
|
||||
if (action.type === "reset-success") return { ...state, resetCredits: { data: action.data, busy: false, error: "" } };
|
||||
if (action.type === "quota-error") return { ...state, quota: { ...state.quota, busy: false, error: action.error } };
|
||||
return { ...state, resetCredits: { ...state.resetCredits, busy: false, error: action.error } };
|
||||
}
|
||||
|
||||
type QuotaDetailsRequest = RequestIdentity & {
|
||||
profileId: string;
|
||||
loadQuota: () => Promise<OfficialQuotaSnapshot>;
|
||||
loadResetCredits: () => Promise<OfficialResetCreditsSnapshot>;
|
||||
dispatch: (action: OfficialQuotaAction) => void;
|
||||
isCurrent: () => boolean;
|
||||
mismatchMessage: string;
|
||||
invalidCountMessage: string;
|
||||
};
|
||||
|
||||
// Start both native reads in the same turn. Each publishes its own result;
|
||||
// waiting for the slower endpoint must never delay the other endpoint's UI.
|
||||
export async function loadOfficialQuotaDetails(request: QuotaDetailsRequest): Promise<void> {
|
||||
const { key, requestId, profileId, dispatch, isCurrent } = request;
|
||||
const quota = (async () => {
|
||||
try {
|
||||
const data = await request.loadQuota();
|
||||
if (!isCurrent()) return;
|
||||
if (data.profileId !== profileId) throw new Error(request.mismatchMessage);
|
||||
dispatch({ type: "quota-success", key, requestId, data });
|
||||
} catch (cause) {
|
||||
if (isCurrent()) dispatch({ type: "quota-error", key, requestId, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
})();
|
||||
const resetCredits = (async () => {
|
||||
try {
|
||||
const data = await request.loadResetCredits();
|
||||
if (!isCurrent()) return;
|
||||
if (data.profileId !== profileId) throw new Error(request.mismatchMessage);
|
||||
if (!Number.isSafeInteger(data.availableCount) || data.availableCount < 0) throw new Error(request.invalidCountMessage);
|
||||
dispatch({ type: "reset-success", key, requestId, data });
|
||||
} catch (cause) {
|
||||
if (isCurrent()) dispatch({ type: "reset-error", key, requestId, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
})();
|
||||
await Promise.all([quota, resetCredits]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type OfficialQuotaWindow = {
|
||||
id: string;
|
||||
windowSeconds: number | null;
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
resetsAt: string | null;
|
||||
};
|
||||
|
||||
export type OfficialQuotaLimit = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
allowed: boolean | null;
|
||||
limitReached: boolean | null;
|
||||
windows: OfficialQuotaWindow[];
|
||||
};
|
||||
|
||||
export type OfficialQuotaSnapshot = {
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
limits: OfficialQuotaLimit[];
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export type OfficialResetCreditsSnapshot = {
|
||||
profileId: string;
|
||||
availableCount: number;
|
||||
checkedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Activity, AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Clock3, Loader2, Plus, Power, RefreshCw, RotateCcw, Server, ShieldCheck, Shuffle, Trash2 } from "lucide-react";
|
||||
import { Button, IconButton } from "../components/ui";
|
||||
import { parseRoutingDraft, routingDraft, routingFields, sameRoutingDraft, type RoutingDraft, type RoutingSettings } from "../routingSettings";
|
||||
import "../styles/provider-failover.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
type Provider = { id: string; providerName: string; model: string; models: string[]; baseUrl?: string | null; eligible: boolean; reason: string | null; official: boolean };
|
||||
type Health = { id: string; state: "closed" | "open" | "half_open"; cooldownSeconds: number; lastStatus: number | null; consecutiveFailures: number; consecutiveSuccesses: number; totalRequests: number; failedRequests: number };
|
||||
export type FailoverStatus = {
|
||||
settings: RoutingSettings; running: boolean; takeoverActive: boolean; autoFailoverActive: boolean; address: string | null;
|
||||
primary: Provider | null; providers: Provider[];
|
||||
runtime: { requestCount: number; failoverCount: number; inFlight: number; successCount: number; failureCount: number; uptimeSeconds: number; lastRequestAt: string | null; lastProviderId: string | null; lastError: string | null; providers: Health[] };
|
||||
message: string | null;
|
||||
};
|
||||
type PageState = { dir: string; status: FailoverStatus; draft: RoutingDraft; baseline: RoutingDraft };
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
function SwitchRow({ id, icon, title, hint, checked, disabled, onChange }: {
|
||||
id: string; icon: React.ReactNode; title: string; hint: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <div className="cx-failover-toggle-row"><span className="cx-failover-symbol">{icon}</span><div className="cx-failover-toggle-copy"><label id={`${id}-label`} htmlFor={id}>{title}</label><p id={`${id}-hint`}>{hint}</p></div><button id={id} className="cx-failover-switch" type="button" role="switch" aria-checked={checked} aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} disabled={disabled} onClick={() => onChange(!checked)}><span /></button></div>;
|
||||
}
|
||||
|
||||
export function ProviderFailoverPage({ lang, configDir, active = true, onChange }: {
|
||||
lang: Language; configDir: string; active?: boolean; onChange?: () => void | Promise<void>;
|
||||
}) {
|
||||
const zh = lang === "zh";
|
||||
const tr = (cn: string, en: string) => zh ? cn : en;
|
||||
const id = useId();
|
||||
const [page, setPage] = useState<PageState | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetting, setResetting] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState("");
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const busy = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const current = useRef({ active, configDir });
|
||||
const pageRef = useRef(page);
|
||||
current.current = { active, configDir };
|
||||
pageRef.current = page;
|
||||
|
||||
const acceptStatus = (status: FailoverStatus, preserveDraft: boolean) => {
|
||||
setPage((previous) => {
|
||||
const saved = routingDraft(status.settings);
|
||||
const keep = preserveDraft && previous?.dir === configDir && !sameRoutingDraft(previous.draft, previous.baseline)
|
||||
&& !sameRoutingDraft(previous.draft, saved);
|
||||
return { dir: configDir, status, draft: keep ? previous.draft : saved, baseline: keep ? previous.baseline : routingDraft(status.settings) };
|
||||
});
|
||||
};
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
useEffect(() => { setNotice(""); setSaveError(""); setWarning(""); }, [configDir]);
|
||||
useEffect(() => {
|
||||
const version = ++generation.current;
|
||||
if (!active) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const valid = () => mounted.current && generation.current === version && current.current.active && current.current.configDir === configDir;
|
||||
const load = async () => {
|
||||
if (!valid()) return;
|
||||
if (busy.current || document.hidden) { timer = setTimeout(() => void load(), 5000); return; }
|
||||
setLoading(pageRef.current?.dir !== configDir);
|
||||
try {
|
||||
const status = await invoke<FailoverStatus>("get_provider_failover", { configDir: configDir || null });
|
||||
if (!valid()) return;
|
||||
acceptStatus(status, true);
|
||||
setWarning("");
|
||||
} catch (error) {
|
||||
if (valid()) setWarning(errorText(error));
|
||||
} finally {
|
||||
if (valid()) { setLoading(false); timer = setTimeout(() => void load(), 5000); }
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { ++generation.current; if (timer) clearTimeout(timer); };
|
||||
}, [active, configDir, refresh]);
|
||||
|
||||
const view = page?.dir === configDir ? page : null;
|
||||
const status = view?.status;
|
||||
const draft = view?.draft;
|
||||
const dirty = Boolean(view && !sameRoutingDraft(view.draft, view.baseline));
|
||||
const changedElsewhere = Boolean(view && dirty && !sameRoutingDraft(routingDraft(view.status.settings), view.baseline));
|
||||
const parsed = draft ? parseRoutingDraft(draft) : null;
|
||||
const selected = draft?.providerIds ?? [];
|
||||
const providers = status?.providers.filter((provider) => !provider.official) ?? [];
|
||||
const candidates = providers.filter((provider) => !selected.includes(provider.id));
|
||||
const invalidQueue = selected.some((item) => !providers.find((provider) => provider.id === item)?.eligible);
|
||||
const enablingAuto = Boolean(draft?.autoFailoverEnabled && !status?.settings.autoFailoverEnabled);
|
||||
const validation = changedElsewhere ? tr("设置已在别处更新,请先撤销修改,再重新保存。", "Settings changed elsewhere. Discard your changes before saving again.")
|
||||
: parsed && !parsed.settings ? tr("请检查标红的输入项。", "Check the highlighted fields.")
|
||||
: draft?.takeoverEnabled && !draft.routerEnabled ? tr("请先开启路由总开关。", "Enable the routing service first.")
|
||||
: draft?.takeoverEnabled && !status?.primary?.eligible ? status?.primary?.reason || tr("请先选择一个可用的供应商或官方账号。", "Select an available provider or official account first.")
|
||||
: enablingAuto && (!draft?.routerEnabled || !draft.takeoverEnabled) ? tr("请先开启本地路由和 Codex 路由。", "Enable the local router and Codex routing first.")
|
||||
: enablingAuto && !selected.length && (!status?.primary?.eligible || status.primary.official) ? tr("请添加至少一个 API 供应商作为 P1。", "Add an API provider as P1 first.")
|
||||
: invalidQueue ? tr("队列中有不可用的供应商,请移除或修复后保存。", "Remove or fix unavailable providers in the queue before saving.") : "";
|
||||
const controlsBusy = saving || resetting !== null;
|
||||
const edit = (patch: Partial<RoutingDraft>) => {
|
||||
if (busy.current) return;
|
||||
setPage((previous) => previous?.dir === configDir ? { ...previous, draft: { ...previous.draft, ...patch } } : previous);
|
||||
setSaveError(""); setNotice("");
|
||||
};
|
||||
const reset = () => {
|
||||
if (!view || busy.current) return;
|
||||
acceptStatus(view.status, false); setNotice(""); setSaveError("");
|
||||
};
|
||||
const reorder = (index: number, offset: number) => {
|
||||
if (index + offset < 0 || index + offset >= selected.length) return;
|
||||
const providerIds = [...selected];
|
||||
[providerIds[index], providerIds[index + offset]] = [providerIds[index + offset], providerIds[index]];
|
||||
edit({ providerIds });
|
||||
};
|
||||
const save = async () => {
|
||||
if (!view || !draft || !dirty || validation || busy.current || !active || !parsed?.settings) return;
|
||||
const version = ++generation.current;
|
||||
busy.current = true; setSaving(true); setSaveError(""); setNotice("");
|
||||
try {
|
||||
const result = await invoke<FailoverStatus>("save_provider_failover", { configDir: configDir || null, settings: parsed.settings });
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
|
||||
acceptStatus(result, false); setWarning("");
|
||||
setNotice(tr("路由设置已保存。", "Routing settings saved."));
|
||||
}
|
||||
if (current.current.configDir === configDir) {
|
||||
try { await onChange?.(); } catch { /* A later status read remains authoritative. */ }
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (mounted.current) { setSaving(false); setRefresh((value) => value + 1); }
|
||||
}
|
||||
};
|
||||
const resetHealth = async (providerId: string) => {
|
||||
if (busy.current || !active) return;
|
||||
const version = ++generation.current;
|
||||
busy.current = true; setResetting(providerId); setSaveError(""); setNotice("");
|
||||
try {
|
||||
const result = await invoke<FailoverStatus>("reset_provider_failover_health", { configDir: configDir || null, providerId });
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
|
||||
acceptStatus(result, true);
|
||||
setNotice(tr("已清除该供应商的故障状态,可重新尝试。", "Provider health reset. It can be tried again."));
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (mounted.current) { setResetting(null); setRefresh((value) => value + 1); }
|
||||
}
|
||||
};
|
||||
const healthFor = (providerId: string) => status?.runtime.providers.find((provider) => provider.id === providerId);
|
||||
const healthBadge = (providerId: string) => {
|
||||
const health = healthFor(providerId);
|
||||
if (!status?.running || !health) return <span className="cx-failover-health cx-failover-health--idle">{tr("未使用", "Not used")}</span>;
|
||||
const label = health.state === "open" ? `${tr("暂时跳过", "Unavailable")} · ${Math.ceil(health.cooldownSeconds)}s`
|
||||
: health.state === "half_open" ? tr("试探恢复", "Recovering") : tr("正常", "Healthy");
|
||||
return <span className={`cx-failover-health cx-failover-health--${health.state}`} title={tr(`连续失败 ${health.consecutiveFailures} 次 · ${health.failedRequests}/${health.totalRequests} 次失败`, `${health.consecutiveFailures} consecutive failures · ${health.failedRequests}/${health.totalRequests} failed`)}><i />{label}</span>;
|
||||
};
|
||||
const primary = status?.primary;
|
||||
const recent = status?.runtime.lastProviderId === primary?.id ? primary : providers.find((provider) => provider.id === status?.runtime.lastProviderId);
|
||||
const uptime = status ? `${Math.floor(status.runtime.uptimeSeconds / 3600)}h ${Math.floor(status.runtime.uptimeSeconds % 3600 / 60)}m` : "—";
|
||||
const fieldError = (key: keyof NonNullable<typeof parsed>["errors"]) => parsed?.errors[key];
|
||||
|
||||
return <section className="cx-failover" aria-busy={loading || controlsBusy}>
|
||||
<header className="cx-failover-heading"><div><h3>{tr("路由与故障转移", "Routing & failover")}</h3><p>{tr("管理 Codex 的连接方式,让请求在供应商故障时继续完成。", "Manage Codex routing and keep requests moving when a provider is unavailable.")}</p></div><IconButton size="sm" label={tr("刷新运行状态", "Refresh routing status")} icon={<RefreshCw size={17} className={loading ? "cx-page-spin" : ""} />} disabled={controlsBusy || loading} onClick={() => setRefresh((value) => value + 1)} /></header>
|
||||
{warning && <div className="cx-failover-message cx-failover-message--warning" role="status"><AlertCircle size={18} /><div>{status && <strong>{tr("状态更新失败,仍显示上次结果。", "Unable to update status. Showing the last result.")}</strong>}<span>{warning}</span></div>{!status && <Button size="sm" variant="secondary" onClick={() => setRefresh((value) => value + 1)}>{tr("重试", "Retry")}</Button>}</div>}
|
||||
{!status || !draft ? (loading ? <div className="cx-failover-loading"><Loader2 size={20} className="cx-page-spin" />{tr("正在读取路由设置…", "Loading routing settings…")}</div> : null) : <>
|
||||
<section className="cx-failover-card">
|
||||
<div className="cx-failover-card-heading"><h4><Server size={18} />{tr("本地路由", "Local routing")}</h4><span className={`cx-failover-state${status.running ? " cx-failover-state--running" : ""}`}><i />{status.running ? tr("运行中", "Running") : tr("已停止", "Stopped")}</span></div>
|
||||
<SwitchRow id={`${id}-router`} icon={<Power size={22} />} title={tr("路由总开关", "Routing service")} hint={tr("启动本机路由服务;更改将在保存后生效。", "Run the local routing service. Changes take effect when saved.")} checked={draft.routerEnabled} disabled={controlsBusy} onChange={(checked) => edit({ routerEnabled: checked, ...(!checked ? { takeoverEnabled: false } : {}) })} />
|
||||
<div className="cx-failover-address-grid">
|
||||
<label htmlFor={`${id}-address`}>{tr("监听地址", "Listen address")}<input id={`${id}-address`} value={draft.listenAddress} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenAddress"))} onChange={(event) => edit({ listenAddress: event.target.value })} spellCheck={false} /><small className={fieldError("listenAddress") ? "cx-failover-field-error" : ""}>{fieldError("listenAddress") ? tr("请输入有效 IPv4、IPv6 或 localhost。", "Enter a valid IPv4, IPv6 or localhost.") : "IPv4 / IPv6 / localhost"}</small></label>
|
||||
<label htmlFor={`${id}-port`}>{tr("监听端口", "Listen port")}<input id={`${id}-port`} type="number" min={1024} max={65535} value={draft.listenPort} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenPort"))} onChange={(event) => edit({ listenPort: event.target.value })} /><small className={fieldError("listenPort") ? "cx-failover-field-error" : ""}>{fieldError("listenPort") ? tr("有效范围:1024–65535", "Valid range: 1024–65535") : status.running ? tr("停止并保存后可修改地址和端口。", "Stop and save before changing the address or port.") : "1024–65535"}</small></label>
|
||||
</div>
|
||||
<SwitchRow id={`${id}-takeover`} icon={<ShieldCheck size={22} />} title={tr("为 Codex 启用路由", "Route Codex requests")} hint={tr("让 Codex 请求经过本地路由;关闭后恢复直接连接。", "Send Codex requests through this router. Turn off to restore direct access.")} checked={draft.takeoverEnabled} disabled={controlsBusy || !draft.takeoverEnabled && (!draft.routerEnabled || !primary?.eligible)} onChange={(checked) => edit({ takeoverEnabled: checked })} />
|
||||
{(!primary?.eligible || !draft.routerEnabled) && <p className="cx-failover-inline-hint">{!draft.routerEnabled ? tr("先开启路由总开关,再选择是否接管 Codex。", "Enable the routing service before routing Codex requests.") : primary?.reason || tr("请先在供应商页面选择一个供应商或已登录的官方账号。", "Select a provider or a signed-in official account first.")}</p>}
|
||||
<div className="cx-failover-primary"><div className="cx-failover-provider-copy"><span className="cx-failover-label">{tr("当前供应商", "Current provider")}</span><strong>{primary?.providerName || tr("尚未选择", "None selected")}</strong><small>{primary?.baseUrl || primary?.model || "—"}</small></div>{primary?.official && <span className="cx-failover-state">{tr("官方登录", "Official account")}</span>}<span className={`cx-failover-state${status.takeoverActive ? " cx-failover-state--running" : ""}`}>{status.takeoverActive ? tr("已接管", "Routed") : tr("直接连接", "Direct access")}</span></div>
|
||||
{status.running && status.address && <div className="cx-failover-service-address"><span>{tr("当前路由地址", "Active route")}</span><code>{status.address}</code></div>}
|
||||
</section>
|
||||
|
||||
<section className="cx-failover-card">
|
||||
<div className="cx-failover-card-heading"><h4><Shuffle size={18} />{tr("自动故障转移", "Automatic failover")}</h4><span className={`cx-failover-state${status.autoFailoverActive ? " cx-failover-state--running" : ""}`}>{status.autoFailoverActive ? tr("已启用", "Active") : tr("未运行", "Inactive")}</span></div>
|
||||
<SwitchRow id={`${id}-auto`} icon={<Shuffle size={22} />} title={tr("自动切换供应商", "Switch providers automatically")} hint={tr("启用并保存后会切到 P1;每次请求按 P1 → P2 → P3 的顺序尝试。", "Enabling and saving switches to P1. Each request tries P1 → P2 → P3 in order.")} checked={draft.autoFailoverEnabled} disabled={controlsBusy || !draft.autoFailoverEnabled && (!draft.routerEnabled || !draft.takeoverEnabled)} onChange={(checked) => edit({ autoFailoverEnabled: checked, ...(checked && !selected.length && primary?.eligible && !primary.official ? { providerIds: [primary.id] } : {}) })} />
|
||||
<div className="cx-failover-queue-heading"><div><h4>{tr("优先级队列", "Priority queue")}</h4><p>{tr("可以提前准备队列。请求保留 Codex 中选择的模型,请确保供应商支持它。", "Prepare your queue at any time. Requests keep the model selected in Codex; providers must support it.")}</p></div><span>{selected.length}/64</span></div>
|
||||
{!selected.length ? <div className="cx-failover-empty"><strong>{tr("还没有队列供应商", "Your queue is empty")}</strong><span>{tr("从下方添加供应商,第一位就是 P1;一个供应商也可以启用。", "Add providers below. The first is P1; a one-provider queue is also supported.")}</span></div> : <ol className="cx-failover-queue">{selected.map((providerId, index) => {
|
||||
const provider = providers.find((item) => item.id === providerId);
|
||||
const health = healthFor(providerId);
|
||||
return <li key={providerId} className={`${index === 0 ? "cx-failover-provider--first" : ""}${!provider?.eligible ? " cx-failover-provider--invalid" : ""}`}><span className="cx-failover-order">P{index + 1}</span><div className="cx-failover-provider-copy"><strong>{provider?.providerName || tr("供应商已删除", "Provider deleted")}</strong><small title={provider?.baseUrl || undefined}>{!provider?.eligible ? provider?.reason || tr("请移除此项或检查供应商配置。", "Remove this item or fix its configuration.") : provider.baseUrl || provider.model}</small></div>{healthBadge(providerId)}<div className="cx-failover-row-actions">{health && health.state !== "closed" && <IconButton size="sm" label={`${tr("重置健康状态", "Reset health")} ${provider?.providerName || ""}`} icon={<RotateCcw size={15} className={resetting === providerId ? "cx-page-spin" : ""} />} disabled={controlsBusy} onClick={() => void resetHealth(providerId)} />}<IconButton size="sm" label={`${tr("上移", "Move up")} ${provider?.providerName || ""}`} icon={<ArrowUp size={15} />} disabled={controlsBusy || index === 0} onClick={() => reorder(index, -1)} /><IconButton size="sm" label={`${tr("下移", "Move down")} ${provider?.providerName || ""}`} icon={<ArrowDown size={15} />} disabled={controlsBusy || index === selected.length - 1} onClick={() => reorder(index, 1)} /><IconButton size="sm" label={`${tr("移除", "Remove")} ${provider?.providerName || ""}`} variant="ghost" icon={<Trash2 size={15} />} disabled={controlsBusy} onClick={() => edit({ providerIds: selected.filter((item) => item !== providerId) })} /></div></li>;
|
||||
})}</ol>}
|
||||
<div className="cx-failover-candidates"><h4>{tr("添加供应商", "Add providers")}</h4>{candidates.length ? <div className="cx-failover-candidate-list">{candidates.map((provider) => <button type="button" className="cx-failover-candidate" key={provider.id} disabled={controlsBusy || !provider.eligible || selected.length >= 64} title={provider.reason || provider.baseUrl || undefined} onClick={() => edit({ providerIds: [...selected, provider.id] })}><div><strong>{provider.providerName}</strong><small>{provider.eligible ? provider.baseUrl || provider.model : provider.reason}</small></div><Plus size={17} /><span className="cx-failover-sr-only">{tr("添加", "Add")}</span></button>)}</div> : <p>{tr("所有可选供应商已在队列中;也可以到供应商页面添加更多。", "All available providers are already queued. Add more on the Providers page.")}</p>}</div>
|
||||
{primary?.official && <p className="cx-failover-inline-hint">{tr("官方账号仅使用当前登录,不参与自动切换。队列只包含 API 供应商。", "Official accounts use the current login only. Only API providers participate in failover.")}</p>}
|
||||
</section>
|
||||
|
||||
<section className="cx-failover-card cx-failover-tuning"><div className="cx-failover-card-heading"><h4><Clock3 size={18} />{tr("重试、超时与恢复", "Retries, timeouts & recovery")}</h4><span className="cx-failover-section-note">{tr("自动故障转移时生效", "Applied during automatic failover")}</span></div>{(["retry", "timeout", "recovery"] as const).map((group) => <div className={`cx-failover-field-group cx-failover-field-group--${group}`} key={group}><h5>{group === "retry" ? tr("重试策略", "Retry policy") : group === "timeout" ? tr("超时时间", "Timeouts") : tr("故障恢复", "Recovery policy")}</h5><div className="cx-failover-fields">{routingFields.filter((field) => field.group === group).map((field) => <label key={field.key} htmlFor={`${id}-${field.key}`}>{zh ? field.zh : field.en}<div className="cx-failover-field-input"><input id={`${id}-${field.key}`} type="number" min={field.min} max={field.max} step={field.key === "circuitErrorRateThreshold" ? "any" : 1} value={draft[field.key]} disabled={controlsBusy} aria-invalid={Boolean(fieldError(field.key))} aria-describedby={`${id}-${field.key}-hint`} onChange={(event) => edit({ [field.key]: event.target.value })} /><span>{field.min}–{field.max}</span></div><small id={`${id}-${field.key}-hint`} className={fieldError(field.key) ? "cx-failover-field-error" : ""}>{fieldError(field.key) ? tr(`请输入 ${field.min}–${field.max} 范围内的${field.key === "circuitErrorRateThreshold" ? "数值" : "整数"}。`, `Enter ${field.min}–${field.max}${field.key === "circuitErrorRateThreshold" ? "." : " (whole numbers)."}`) : zh ? field.hintZh : field.hintEn}</small></label>)}</div></div>)}</section>
|
||||
|
||||
<section className="cx-failover-card cx-failover-runtime"><div className="cx-failover-card-heading"><h4><Activity size={18} />{tr("运行状态", "Runtime status")}</h4><span className="cx-failover-section-note">{tr("运行时间", "Uptime")} {uptime}</span></div><dl><div><dt>{tr("处理请求", "Requests")}</dt><dd>{status.runtime.requestCount.toLocaleString()}</dd></div><div><dt>{tr("成功 / 失败", "Succeeded / failed")}</dt><dd>{status.runtime.successCount.toLocaleString()} <span>/ {status.runtime.failureCount.toLocaleString()}</span></dd></div><div><dt>{tr("自动切换", "Failovers")}</dt><dd>{status.runtime.failoverCount.toLocaleString()}</dd></div><div><dt>{tr("正在处理", "In progress")}</dt><dd>{status.runtime.inFlight.toLocaleString()}</dd></div></dl><div className="cx-failover-runtime-current"><span>{tr("最近使用", "Last used")}</span><strong>{recent?.providerName || tr("等待请求", "Waiting for requests")}</strong>{status.runtime.lastRequestAt && <time>{new Date(status.runtime.lastRequestAt).toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit" })}</time>}</div>{status.message && <p className="cx-failover-runtime-note">{status.message}</p>}{status.runtime.lastError && <p className="cx-failover-runtime-note"><AlertCircle size={15} />{status.runtime.lastError}</p>}</section>
|
||||
<p className="cx-failover-bottom-note">{tr("回复已经开始后不会重复发送。关闭 Codex-X 窗口仍会在后台运行;完全退出时恢复直接连接。", "A reply that has already started is never replayed. Closing this window keeps routing active; quitting Codex-X restores direct access.")}</p>
|
||||
{saveError && <div className="cx-failover-message cx-failover-message--warning" role="alert"><AlertCircle size={18} /><span>{saveError}</span></div>}
|
||||
{notice && <div className="cx-failover-message cx-failover-message--success" role="status"><CheckCircle2 size={18} /><span>{notice}</span></div>}
|
||||
<footer className="cx-failover-save"><div aria-live="polite" className={validation ? "cx-failover-field-error" : ""}>{validation || (dirty ? tr("有未保存的修改", "Unsaved changes") : tr("更改将在保存后生效", "Changes take effect when saved"))}</div><div><Button size="sm" variant="secondary" disabled={!dirty || controlsBusy} onClick={reset} icon={<RotateCcw size={15} />}>{tr("撤销修改", "Discard changes")}</Button><Button size="sm" disabled={!dirty || controlsBusy || Boolean(validation)} icon={saving ? <Loader2 size={15} className="cx-page-spin" /> : undefined} onClick={() => void save()}>{saving ? tr("保存中…", "Saving…") : tr("保存设置", "Save settings")}</Button></div></footer>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -11,18 +11,25 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FilePlus2,
|
||||
Gauge,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Ref } from "react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { OfficialQuotaDialog } from "../components/OfficialQuotaDialog";
|
||||
import { OfficialAccountBadge } from "../components/OfficialAccountBadge";
|
||||
import { ProviderModelMappings, validateProviderModelMappings } from "../components/ProviderModelMappings";
|
||||
import { ProviderPresetPicker } from "../components/ProviderPresetPicker";
|
||||
import { PROVIDER_PRESETS, getProviderPreset, getProviderPresetVariant } from "../providerPresets";
|
||||
import { Button, Checkbox, ModalShell } from "../components/ui";
|
||||
import type { ProviderMode } from "../types";
|
||||
import type { ProviderMode, ProviderModelMapping } from "../types";
|
||||
import { moveProviderRow, providerRowKey } from "../providerRowOrder";
|
||||
import "../styles/providers-page.css";
|
||||
|
||||
export type ProviderRowSource = "official" | "local" | "detected";
|
||||
@@ -33,11 +40,16 @@ export type ProviderRow = {
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
modelDisplayName?: string;
|
||||
apiKey?: string;
|
||||
wireApi: string;
|
||||
requiresOpenaiAuth: boolean;
|
||||
isCurrent: boolean;
|
||||
isDefaultOfficial?: boolean;
|
||||
email?: string | null;
|
||||
planType?: string | null;
|
||||
hasAuth?: boolean;
|
||||
canQueryQuota?: boolean;
|
||||
sourceLabel?: string;
|
||||
editable?: boolean;
|
||||
duplicable?: boolean;
|
||||
@@ -92,7 +104,6 @@ export type ProviderCopy = {
|
||||
officialTomlLabel: string;
|
||||
officialSaveLabel: string;
|
||||
loadCcSwitchOfficialLabel: string;
|
||||
restoreOfficialLabel: string;
|
||||
resetOfficialLabel: string;
|
||||
resetOfficialTitle: string;
|
||||
resetOfficialDescription: string;
|
||||
@@ -134,16 +145,22 @@ export type ProviderOfficialInfo = {
|
||||
|
||||
export type ProvidersPageProps = {
|
||||
lang: "zh" | "en";
|
||||
configDir: string;
|
||||
copy: ProviderCopy;
|
||||
mode: ProviderMode;
|
||||
providerRows: readonly ProviderRow[];
|
||||
loading: boolean;
|
||||
testingId: string;
|
||||
actionBusy?: string;
|
||||
orderBusy?: boolean;
|
||||
onReorderProviders: (order: string[]) => Promise<boolean>;
|
||||
editingProviderId: string | null;
|
||||
creatingProvider: boolean;
|
||||
providerKind: "api" | "official";
|
||||
selectedPresetId: string;
|
||||
selectedPresetVariantId: string;
|
||||
providerForm: ProviderFormValue;
|
||||
providerModelMappings: readonly ProviderModelMapping[];
|
||||
onProviderModelMappingsChange: (rows: ProviderModelMapping[]) => void;
|
||||
officialForm: OfficialFormValue;
|
||||
officialProfileIsDefault: boolean;
|
||||
canLoadCurrentOfficial: boolean;
|
||||
@@ -158,10 +175,10 @@ export type ProvidersPageProps = {
|
||||
fetchingModels: boolean;
|
||||
onImportCcSwitch: () => void;
|
||||
onAddProvider: () => void;
|
||||
onProviderKindChange: (kind: "api" | "official") => void;
|
||||
onPresetSelect: (id: string) => void;
|
||||
onPresetVariantSelect: (id: string) => void;
|
||||
onLoadCcSwitchOfficial: () => void;
|
||||
onLoadCurrentOfficial: () => void;
|
||||
onRestoreOfficial: () => void;
|
||||
onResetOfficial: () => void;
|
||||
onEnableProvider: (row: ProviderRow) => void;
|
||||
onTestProvider: (row: ProviderRow) => void;
|
||||
@@ -182,7 +199,7 @@ export type ProvidersPageProps = {
|
||||
onWireApiChange: (value: string) => void;
|
||||
onRequiresAuthChange: (value: boolean) => void;
|
||||
onToggleApiKeyVisibility: () => void;
|
||||
onProviderTomlDraftChange: (value: string) => void;
|
||||
onProviderTomlDraftChange: (value: string, origin?: "manual" | "context") => void;
|
||||
onResetProviderToml: () => void;
|
||||
onSaveProvider: () => void;
|
||||
};
|
||||
@@ -317,28 +334,19 @@ function ContextWindowControl({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderKindSelect({
|
||||
lang,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
disabled,
|
||||
}: Pick<ProvidersPageProps, "lang" | "creatingProvider" | "providerKind" | "onProviderKindChange"> & { disabled: boolean }) {
|
||||
function ProviderPresetSection({ lang, creatingProvider, selectedPresetId, selectedPresetVariantId, onPresetSelect, onPresetVariantSelect, disabled }: Pick<ProvidersPageProps, "lang" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect"> & { disabled: boolean }) {
|
||||
if (!creatingProvider) return null;
|
||||
return (
|
||||
<div className="cx-providers-form-grid cx-providers-form-grid--single">
|
||||
<Field label={lang === "zh" ? "供应商类型" : "Provider type"}>
|
||||
<select
|
||||
value={providerKind}
|
||||
onChange={(event) => onProviderKindChange(event.target.value === "official" ? "official" : "api")}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="api">{lang === "zh" ? "第三方 API" : "Third-party API"}</option>
|
||||
<option value="official">{lang === "zh" ? "官方 Codex 登录" : "Official Codex login"}</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
const preset = getProviderPreset(selectedPresetId);
|
||||
const variant = getProviderPresetVariant(selectedPresetId, selectedPresetVariantId);
|
||||
return <ProviderPresetPicker lang={lang} presets={PROVIDER_PRESETS} selectedId={selectedPresetId} onSelect={onPresetSelect} disabled={disabled}>
|
||||
{variant && <div className="cx-preset-variant-panel">
|
||||
{preset && preset.variants.length > 1 && <div className="cx-preset-variants" role="group" aria-label={lang === "zh" ? "接入方式" : "API service"}>
|
||||
<span>{lang === "zh" ? "接入方式" : "API service"}</span>
|
||||
{preset.variants.map((item) => <button key={item.id} type="button" aria-pressed={item.id === variant.id} disabled={disabled} onClick={() => onPresetVariantSelect(item.id)}>{lang === "zh" ? item.label : item.labelEn}</button>)}
|
||||
</div>}
|
||||
<p>{lang === "zh" ? variant.note : variant.noteEn}</p>
|
||||
</div>}
|
||||
</ProviderPresetPicker>;
|
||||
}
|
||||
|
||||
function ProviderAvatar({ row }: { row: ProviderRow }) {
|
||||
@@ -384,23 +392,98 @@ function ActionIconButton({
|
||||
}
|
||||
|
||||
function ListPage({
|
||||
lang,
|
||||
configDir,
|
||||
copy,
|
||||
providerRows,
|
||||
loading,
|
||||
testingId,
|
||||
actionBusy,
|
||||
orderBusy,
|
||||
onImportCcSwitch,
|
||||
onAddProvider,
|
||||
onRestoreOfficial,
|
||||
onEnableProvider,
|
||||
onTestProvider,
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
onReorderProviders,
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "orderBusy" | "onReorderProviders" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [quotaSelection, setQuotaSelection] = useState<{ id: string; configDir: string; email: string | null } | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
const sortingDisabled = providerActionsBusy || Boolean(orderBusy) || providerRows.length < 2;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pointerDragRef = useRef<{ key: string; pointerId: number; startY: number; moved: boolean } | null>(null);
|
||||
const pointerPositionRef = useRef({ x: 0, y: 0 });
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const dropTargetRef = useRef<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const [draggedKey, setDraggedKey] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const finishDrag = () => {
|
||||
if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = null;
|
||||
pointerDragRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
setDraggedKey("");
|
||||
setDropTarget(null);
|
||||
};
|
||||
useEffect(() => { finishDrag(); }, [configDir]);
|
||||
useEffect(() => () => { if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current); }, []);
|
||||
const reorder = async (source: string, target: string, position: "before" | "after") => {
|
||||
const current = providerRows.map(providerRowKey);
|
||||
const next = moveProviderRow(current, source, target, position);
|
||||
if (next.some((key, index) => key !== current[index])) {
|
||||
await onReorderProviders(next);
|
||||
if (document.activeElement === document.body || document.activeElement?.classList.contains("cx-providers-drag-handle")) {
|
||||
const card = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-provider-key]") || []).find((item) => item.dataset.providerKey === source);
|
||||
card?.querySelector<HTMLButtonElement>(".cx-providers-drag-handle")?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
const updateDropTarget = (clientX: number, clientY: number) => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (clientX < bounds.left || clientX > bounds.right) {
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const cards = Array.from(list.querySelectorAll<HTMLElement>("[data-provider-key]"));
|
||||
const card = cards.find((item) => clientY < item.getBoundingClientRect().bottom) || cards[cards.length - 1];
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const target = { key: card.dataset.providerKey!, position: clientY < rect.top + rect.height / 2 ? "before" as const : "after" as const };
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
const startAutoScroll = () => {
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
const tick = () => {
|
||||
const list = listRef.current;
|
||||
if (!pointerDragRef.current?.moved || !list) { scrollFrameRef.current = null; return; }
|
||||
const { x, y } = pointerPositionRef.current;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (x >= bounds.left && x <= bounds.right) {
|
||||
const speed = y < bounds.top + 48 ? -Math.min(16, (bounds.top + 48 - y) / 3)
|
||||
: y > bounds.bottom - 48 ? Math.min(16, (y - bounds.bottom + 48) / 3) : 0;
|
||||
const previous = list.scrollTop;
|
||||
list.scrollTop += speed;
|
||||
if (list.scrollTop !== previous) updateDropTarget(x, y);
|
||||
}
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
const quotaProfile = quotaSelection?.configDir === configDir
|
||||
? providerRows.find((row) => row.source === "official" && row.id === quotaSelection.id && row.canQueryQuota && (row.email ?? null) === quotaSelection.email)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (quotaSelection && !quotaProfile) setQuotaSelection(null);
|
||||
}, [quotaSelection, quotaProfile]);
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (!deleting) setProviderToDelete(null);
|
||||
@@ -441,27 +524,75 @@ function ListPage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="cx-providers-list" role="list">
|
||||
<p className="cx-providers-sort-hint">{lang === "zh" ? "拖动卡片左侧手柄调整顺序,自动保存。" : "Drag the handle on the left to reorder. Changes save automatically."}</p>
|
||||
<div className="cx-providers-list" role="list" ref={listRef}>
|
||||
{providerRows.length === 0 ? (
|
||||
<div className="cx-providers-empty" role="status">{copy.noProviders}</div>
|
||||
) : providerRows.map((row) => {
|
||||
const testingKey = row.testingKey || `${row.source}-${row.id}`;
|
||||
const isTesting = testingId === testingKey;
|
||||
const isProtectedOfficial = row.source === "official" && row.isDefaultOfficial === true;
|
||||
const key = providerRowKey(row);
|
||||
const modelName = row.modelDisplayName || row.model || (lang === "zh" ? "跟随 Codex" : "Follow Codex");
|
||||
return (
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}${draggedKey === key ? " cx-providers-row--dragging" : ""}${dropTarget?.key === key && draggedKey !== key ? ` cx-providers-row--drop-${dropTarget.position}` : ""}`} key={key} role="listitem" data-provider-key={key}>
|
||||
<button type="button" className="cx-providers-drag-handle" disabled={sortingDisabled}
|
||||
aria-label={lang === "zh" ? `排序 ${row.providerName}:拖动或按上下箭头移动` : `Reorder ${row.providerName}: drag or use Up and Down arrows`}
|
||||
title={lang === "zh" ? "拖动排序,也可按上下箭头移动" : "Drag to reorder, or use Up and Down arrows"}
|
||||
onPointerDown={(event) => {
|
||||
if (sortingDisabled || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
pointerDragRef.current = { key, pointerId: event.pointerId, startY: event.clientY, moved: false };
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId || sortingDisabled) return;
|
||||
if (!drag.moved && Math.abs(event.clientY - drag.startY) < 5) return;
|
||||
drag.moved = true;
|
||||
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
|
||||
setDraggedKey(drag.key);
|
||||
updateDropTarget(event.clientX, event.clientY);
|
||||
startAutoScroll();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
const target = dropTargetRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
finishDrag();
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
const bounds = listRef.current?.getBoundingClientRect();
|
||||
const inside = bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom;
|
||||
if (drag.moved && target && inside && !sortingDisabled) void reorder(drag.key, target.key, target.position);
|
||||
}}
|
||||
onPointerCancel={finishDrag}
|
||||
onLostPointerCapture={finishDrag}
|
||||
onKeyDown={(event) => {
|
||||
if (sortingDisabled || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const index = providerRows.findIndex((entry) => providerRowKey(entry) === key);
|
||||
const target = providerRows[index + (event.key === "ArrowUp" ? -1 : 1)];
|
||||
if (target) void reorder(key, providerRowKey(target), event.key === "ArrowUp" ? "before" : "after");
|
||||
}}
|
||||
><GripVertical size={16} aria-hidden="true" /></button>
|
||||
<ProviderAvatar row={row} />
|
||||
<div className="cx-providers-row-main">
|
||||
<div className="cx-providers-row-title">
|
||||
<strong>{row.providerName}</strong>
|
||||
{row.sourceLabel && (
|
||||
<span className={`cx-providers-source-badge${row.source === "official" ? " cx-providers-source-badge--official" : ""}`}>
|
||||
{row.sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
<div className="cx-providers-row-content">
|
||||
<div className="cx-providers-row-main">
|
||||
<div className="cx-providers-row-title">
|
||||
<strong title={row.providerName}>{row.providerName}</strong>
|
||||
{row.sourceLabel && (
|
||||
<span className={`cx-providers-source-badge${row.source === "official" ? " cx-providers-source-badge--official" : ""}`}>
|
||||
{row.sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
|
||||
<div className="cx-providers-row-model" title={row.model || modelName}><span>{lang === "zh" ? "模型" : "Model"}</span><b>{modelName}</b>{row.modelDisplayName && row.modelDisplayName !== row.model && <code>{row.model}</code>}</div>
|
||||
{row.source !== "official" && row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
|
||||
</div>
|
||||
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
|
||||
{row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
|
||||
</div>
|
||||
{row.source === "official" && <OfficialAccountBadge lang={lang} email={row.email} planType={row.planType} hasAuth={Boolean(row.hasAuth)} canQueryQuota={Boolean(row.canQueryQuota)} className="cx-providers-login-badge" />}
|
||||
<div className="cx-providers-row-actions">
|
||||
{row.isCurrent && <span className="cx-providers-current-badge"><span aria-hidden="true" />{copy.currentLabel}</span>}
|
||||
<button
|
||||
@@ -472,12 +603,16 @@ function ListPage({
|
||||
>
|
||||
{copy.enableLabel}
|
||||
</button>
|
||||
{row.source === "official" && row.isDefaultOfficial && (
|
||||
{row.source === "official" && (
|
||||
<ActionIconButton
|
||||
icon={RotateCcw}
|
||||
label={copy.restoreOfficialLabel}
|
||||
onClick={onRestoreOfficial}
|
||||
disabled={providerActionsBusy}
|
||||
icon={Gauge}
|
||||
label={row.canQueryQuota
|
||||
? (lang === "zh" ? "查看额度" : "View quota")
|
||||
: row.hasAuth
|
||||
? (lang === "zh" ? "查看额度:此认证不支持订阅额度查询" : "View quota: these credentials do not support subscription quota queries")
|
||||
: (lang === "zh" ? "查看额度:请先登录官方 Codex" : "View quota: sign in to official Codex first")}
|
||||
onClick={() => setQuotaSelection({ id: row.id, configDir, email: row.email ?? null })}
|
||||
disabled={providerActionsBusy || !row.canQueryQuota}
|
||||
/>
|
||||
)}
|
||||
{row.testable !== false && (
|
||||
@@ -494,8 +629,14 @@ function ListPage({
|
||||
{row.duplicable && (
|
||||
<ActionIconButton icon={Copy} label={copy.duplicateLabel} onClick={() => onDuplicateProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{row.deletable && (
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
|
||||
{(row.deletable || isProtectedOfficial) && (
|
||||
<ActionIconButton
|
||||
icon={Trash2}
|
||||
label={isProtectedOfficial ? (lang === "zh" ? "默认官方配置不可删除" : "The default official profile cannot be deleted") : copy.removeLabel}
|
||||
onClick={() => { if (!isProtectedOfficial) setProviderToDelete(row); }}
|
||||
disabled={providerActionsBusy || isProtectedOfficial}
|
||||
danger={!isProtectedOfficial}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
@@ -503,6 +644,13 @@ function ListPage({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<OfficialQuotaDialog
|
||||
lang={lang}
|
||||
configDir={configDir}
|
||||
profile={quotaProfile ? { id: quotaProfile.id, name: quotaProfile.providerName, email: quotaProfile.email ?? null, planType: quotaProfile.planType } : null}
|
||||
onClose={() => setQuotaSelection(null)}
|
||||
/>
|
||||
|
||||
<ModalShell
|
||||
open={Boolean(providerToDelete)}
|
||||
onClose={closeDeleteDialog}
|
||||
@@ -559,8 +707,10 @@ function OfficialForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
selectedPresetId,
|
||||
selectedPresetVariantId,
|
||||
onPresetSelect,
|
||||
onPresetVariantSelect,
|
||||
officialForm,
|
||||
officialProfileIsDefault,
|
||||
canLoadCurrentOfficial,
|
||||
@@ -577,9 +727,8 @@ function OfficialForm({
|
||||
onSaveOfficial,
|
||||
onLoadCcSwitchOfficial,
|
||||
onLoadCurrentOfficial,
|
||||
onRestoreOfficial,
|
||||
onResetOfficial,
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onResetOfficial">) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
|
||||
@@ -606,7 +755,7 @@ function OfficialForm({
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
|
||||
<ProviderPresetSection lang={lang} creatingProvider={creatingProvider} selectedPresetId={selectedPresetId} selectedPresetVariantId={selectedPresetVariantId} onPresetSelect={onPresetSelect} onPresetVariantSelect={onPresetVariantSelect} disabled={formBusy} />
|
||||
<div className="cx-providers-info-grid">
|
||||
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
|
||||
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
|
||||
@@ -656,14 +805,9 @@ function OfficialForm({
|
||||
{copy.loadCcSwitchOfficialLabel}
|
||||
</button>
|
||||
{showDefaultActions && (
|
||||
<>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={formBusy}>
|
||||
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
</button>
|
||||
</>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
</div>
|
||||
@@ -694,9 +838,13 @@ function ProviderForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
selectedPresetId,
|
||||
selectedPresetVariantId,
|
||||
onPresetSelect,
|
||||
onPresetVariantSelect,
|
||||
providerForm,
|
||||
providerModelMappings,
|
||||
onProviderModelMappingsChange,
|
||||
loading,
|
||||
editingProviderId,
|
||||
providerAuthPreview,
|
||||
@@ -717,11 +865,12 @@ function ProviderForm({
|
||||
onProviderTomlDraftChange,
|
||||
onResetProviderToml,
|
||||
onSaveProvider,
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "providerForm" | "providerModelMappings" | "onProviderModelMappingsChange" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
const modelListId = useId();
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy;
|
||||
const mappingsValid = validateProviderModelMappings(providerModelMappings, providerForm.model, lang).valid;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -733,7 +882,7 @@ function ProviderForm({
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
|
||||
<ProviderPresetSection lang={lang} creatingProvider={creatingProvider} selectedPresetId={selectedPresetId} selectedPresetVariantId={selectedPresetVariantId} onPresetSelect={onPresetSelect} onPresetVariantSelect={onPresetVariantSelect} disabled={formBusy} />
|
||||
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading">
|
||||
@@ -815,6 +964,16 @@ function ProviderForm({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProviderModelMappings
|
||||
key={editingProviderId ?? "new-provider"}
|
||||
lang={lang}
|
||||
rows={providerModelMappings}
|
||||
currentModel={providerForm.model}
|
||||
availableModels={availableModels}
|
||||
disabled={formBusy}
|
||||
onChange={onProviderModelMappingsChange}
|
||||
/>
|
||||
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading"><div><h3>{copy.authPreviewTitle}</h3><p>{copy.authPreviewDescription}</p></div></div>
|
||||
<div className="cx-providers-preview">{providerAuthPreview}</div>
|
||||
@@ -824,7 +983,7 @@ function ProviderForm({
|
||||
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<div className="cx-providers-context-actions">
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={onProviderTomlDraftChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={(value) => onProviderTomlDraftChange(value, "context")} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -832,7 +991,8 @@ function ProviderForm({
|
||||
</section>
|
||||
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy}>
|
||||
{!mappingsValid && <span className="cx-provider-mappings-save-hint">{lang === "zh" ? "请先修正模型映射中的错误。" : "Correct the model mapping errors before saving."}</span>}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy || !mappingsValid}>
|
||||
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{loading ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
@@ -842,8 +1002,12 @@ function ProviderForm({
|
||||
}
|
||||
|
||||
export function ProvidersPage(props: ProvidersPageProps) {
|
||||
const pageKey = props.creatingProvider && props.mode !== "list"
|
||||
? "providers:add"
|
||||
: `providers:${props.mode}`;
|
||||
|
||||
return (
|
||||
<PageTransition pageKey={`providers:${props.mode}`}>
|
||||
<PageTransition pageKey={pageKey}>
|
||||
<section className={`cx-providers cx-page cx-providers--${props.mode}`}>
|
||||
{props.mode === "list" && <ListPage {...props} />}
|
||||
{props.mode === "official" && <OfficialForm {...props} />}
|
||||
|
||||
@@ -1,18 +1,70 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Folder,
|
||||
FolderTree,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
Pin,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Checkbox, ModalShell, cx } from "../components/ui";
|
||||
import {
|
||||
mergeSessionDirectoryGroups,
|
||||
pinnedSessionDirectoryKeys,
|
||||
readSessionPinPreferences,
|
||||
sessionPinStorageKey,
|
||||
sessionsForDirectoryGroup,
|
||||
toggleFolderPins,
|
||||
type SessionDirectoryIdentities,
|
||||
type SessionPinPreferences,
|
||||
} from "../sessionDirectoryIdentity";
|
||||
import "../styles/session-management.css";
|
||||
|
||||
const SESSION_ROW_HEIGHT = 58;
|
||||
const SESSION_COLUMN_HEADER_HEIGHT = 34;
|
||||
const SESSION_VIRTUAL_OVERSCAN_ROWS = 8;
|
||||
const DEFAULT_SESSION_VIEWPORT_HEIGHT = 640;
|
||||
const SESSION_ALL_FOLDERS_KEY = "__codexx_all_folders__";
|
||||
// Defer the browser storage getter so unavailable storage is caught by the reader.
|
||||
const sessionPinStorage = { getItem: (key: string) => localStorage.getItem(key) };
|
||||
|
||||
type ScopedSessionPinPreferences = SessionPinPreferences & {
|
||||
storageKey: string;
|
||||
};
|
||||
|
||||
function folderDisplayName(value: string, missing: string) {
|
||||
if (!value) return missing;
|
||||
const parts = value.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || missing;
|
||||
}
|
||||
|
||||
type SessionVirtualRow = {
|
||||
kind: "session";
|
||||
key: string;
|
||||
top: number;
|
||||
height: number;
|
||||
item: SessionPreview;
|
||||
};
|
||||
|
||||
function findVirtualRowIndex(rows: SessionVirtualRow[], offset: number) {
|
||||
let low = 0;
|
||||
let high = rows.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (rows[middle].top + rows[middle].height <= offset) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
export type Lang = "zh" | "en";
|
||||
|
||||
export type SessionPreview = {
|
||||
@@ -31,6 +83,8 @@ export type SessionPreview = {
|
||||
|
||||
export type SessionSyncStatus = {
|
||||
codexDir: string;
|
||||
directoryIdentities?: SessionDirectoryIdentities;
|
||||
pinScopeKey?: string | null;
|
||||
targetProvider: string;
|
||||
rolloutFiles: number;
|
||||
sessionMetaCount: number;
|
||||
@@ -73,6 +127,7 @@ type SessionManagementPageProps = {
|
||||
actionBusy: string;
|
||||
sessionDeleteConfirmOpen: boolean;
|
||||
sessionDeleteBusy: boolean;
|
||||
sessionExportBusy: boolean;
|
||||
sessionDeleteSafetyConfirmed: boolean;
|
||||
onCheckSessions: () => void;
|
||||
onSyncSessions: () => void;
|
||||
@@ -84,6 +139,7 @@ type SessionManagementPageProps = {
|
||||
onSetSessionGroupSelected: (sessions: SessionPreview[], checked: boolean) => void;
|
||||
onCloseDeleteConfirm: () => void;
|
||||
onDeleteSelectedSessions: () => void;
|
||||
onExportSessions: (ids: string[]) => void;
|
||||
onDeleteSafetyConfirmedChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -138,6 +194,7 @@ export function SessionManagementPage({
|
||||
actionBusy,
|
||||
sessionDeleteConfirmOpen,
|
||||
sessionDeleteBusy,
|
||||
sessionExportBusy,
|
||||
sessionDeleteSafetyConfirmed,
|
||||
onCheckSessions,
|
||||
onSyncSessions,
|
||||
@@ -149,14 +206,16 @@ export function SessionManagementPage({
|
||||
onSetSessionGroupSelected,
|
||||
onCloseDeleteConfirm,
|
||||
onDeleteSelectedSessions,
|
||||
onExportSessions,
|
||||
onDeleteSafetyConfirmedChange,
|
||||
}: SessionManagementPageProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const codexDir = sessionStatus?.codexDir || "default";
|
||||
const copy = isChinese
|
||||
? {
|
||||
syncEyebrow: "会话同步",
|
||||
title: "会话管理",
|
||||
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
|
||||
description: "同步普通会话到共享列表,内部任务不参与同步。不会修改聊天内容。",
|
||||
syncTo: "同步到",
|
||||
check: "检查会话",
|
||||
checking: "检查中...",
|
||||
@@ -168,26 +227,38 @@ export function SessionManagementPage({
|
||||
allSynced: "全部会话已同步",
|
||||
sessionCount: (count: number) => `${count} 条会话`,
|
||||
local: "本地会话",
|
||||
list: "会话列表",
|
||||
list: "文件夹与会话",
|
||||
shown: (shown: number, total: number) => `展示 ${shown} / ${total} 条`,
|
||||
loaded: (count: number) => `当前加载 ${count} 条`,
|
||||
search: "搜索标题 / 项目 / 供应商 / ID",
|
||||
groupByProject: "按项目路径分组",
|
||||
groupByProject: "文件夹视图",
|
||||
showInternal: (count: number) => `显示内部会话 (${count})`,
|
||||
deleteSelected: "删除选中",
|
||||
exportSelected: "导出选中",
|
||||
exportOne: "导出为 Markdown",
|
||||
exporting: "导出中…",
|
||||
exportHint: "单个会话保存为 Markdown;多个会话打包为 ZIP",
|
||||
deleteMany: (count: number) => `永久删除 ${count} 条`,
|
||||
selectAll: "选择当前列表中的全部会话",
|
||||
selectProject: (path: string, count: number) => `选择项目 ${path} 的 ${count} 条会话`,
|
||||
projectCount: (count: number, truncated: boolean) => `${truncated ? "已加载 " : ""}${count} 条`,
|
||||
projectShown: (shown: number, total: number) => `显示 ${shown} / 共 ${total} 条`,
|
||||
pinFolder: "置顶文件夹",
|
||||
unpinFolder: "取消置顶文件夹",
|
||||
pinSession: "置顶会话",
|
||||
unpinSession: "取消置顶会话",
|
||||
allSessions: "全部会话",
|
||||
folders: "工作文件夹",
|
||||
selectSession: "选择会话",
|
||||
archived: "已归档",
|
||||
internal: "内部",
|
||||
internalHint: "内部任务仅供查看,不参与会话同步。",
|
||||
pending: "待同步",
|
||||
unknownProvider: "未知供应商",
|
||||
noModel: "未记录",
|
||||
noMatch: "没有匹配的会话。",
|
||||
noSessions: "还没有读取到会话。点击右上角“检查会话”刷新。",
|
||||
loadingMore: "正在加载更多会话…",
|
||||
diagnostics: "诊断信息",
|
||||
diagnosticsCount: (count: number) => `${count} 条 · 点击查看`,
|
||||
deleteTitle: (count: number) => `永久删除 ${count} 条会话`,
|
||||
@@ -205,7 +276,7 @@ export function SessionManagementPage({
|
||||
: {
|
||||
syncEyebrow: "SESSION SYNC",
|
||||
title: "Session management",
|
||||
description: "Keep local sessions in one history shared by official and third-party providers. Chat content is not changed.",
|
||||
description: "Keep user conversations in one shared history. Internal tasks are excluded from sync; chat content is unchanged.",
|
||||
syncTo: "Sync to",
|
||||
check: "Check sessions",
|
||||
checking: "Checking...",
|
||||
@@ -217,26 +288,38 @@ export function SessionManagementPage({
|
||||
allSynced: "All sessions are synced",
|
||||
sessionCount: (count: number) => `${count} sessions`,
|
||||
local: "LOCAL SESSIONS",
|
||||
list: "Sessions",
|
||||
list: "Folders & sessions",
|
||||
shown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
loaded: (count: number) => `${count} loaded`,
|
||||
search: "Search title / project / provider / ID",
|
||||
groupByProject: "Group by project path",
|
||||
groupByProject: "Folder view",
|
||||
showInternal: (count: number) => `Show internal sessions (${count})`,
|
||||
deleteSelected: "Delete selected",
|
||||
exportSelected: "Export selected",
|
||||
exportOne: "Export as Markdown",
|
||||
exporting: "Exporting…",
|
||||
exportHint: "Save one session as Markdown, or multiple sessions in a ZIP",
|
||||
deleteMany: (count: number) => `Delete ${count} permanently`,
|
||||
selectAll: "Select all sessions in the current list",
|
||||
selectProject: (path: string, count: number) => `Select ${count} sessions in ${path}`,
|
||||
projectCount: (count: number, truncated: boolean) => `${count}${truncated ? " loaded" : ""}`,
|
||||
projectShown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
pinFolder: "Pin folder",
|
||||
unpinFolder: "Unpin folder",
|
||||
pinSession: "Pin session",
|
||||
unpinSession: "Unpin session",
|
||||
allSessions: "All sessions",
|
||||
folders: "Workspace folders",
|
||||
selectSession: "Select session",
|
||||
archived: "Archived",
|
||||
internal: "Internal",
|
||||
internalHint: "Internal tasks are available for inspection and excluded from session sync.",
|
||||
pending: "Needs sync",
|
||||
unknownProvider: "Unknown provider",
|
||||
noModel: "Not recorded",
|
||||
noMatch: "No matching sessions.",
|
||||
noSessions: "No sessions loaded. Click Check sessions to refresh.",
|
||||
loadingMore: "Loading more sessions…",
|
||||
diagnostics: "Diagnostics",
|
||||
diagnosticsCount: (count: number) => `${count} · click to view`,
|
||||
deleteTitle: (count: number) => `Permanently delete ${count} session(s)`,
|
||||
@@ -258,10 +341,173 @@ export function SessionManagementPage({
|
||||
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
|
||||
];
|
||||
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
|
||||
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
|
||||
const sessionScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const directoryIdentities = sessionStatus?.directoryIdentities;
|
||||
const pinScopeKey = sessionStatus?.pinScopeKey;
|
||||
const pinStorageKey = useMemo(() => sessionPinStorageKey(codexDir, pinScopeKey), [codexDir, pinScopeKey]);
|
||||
const [pinPreferences, setPinPreferences] = useState<ScopedSessionPinPreferences>({
|
||||
storageKey: "",
|
||||
folders: [],
|
||||
sessions: [],
|
||||
});
|
||||
const [selectedFolderKey, setSelectedFolderKey] = useState(SESSION_ALL_FOLDERS_KEY);
|
||||
const [sessionScrollMetrics, setSessionScrollMetrics] = useState({
|
||||
scrollTop: 0,
|
||||
viewportHeight: DEFAULT_SESSION_VIEWPORT_HEIGHT,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPinPreferences({ storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) });
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}, [pinStorageKey]);
|
||||
|
||||
const activePinPreferences = pinPreferences.storageKey === pinStorageKey
|
||||
? pinPreferences
|
||||
: { storageKey: pinStorageKey, folders: [], sessions: [] };
|
||||
const pinnedFolderSet = useMemo(() => pinnedSessionDirectoryKeys(activePinPreferences.folders, directoryIdentities), [activePinPreferences.folders, directoryIdentities]);
|
||||
const pinnedSessionSet = useMemo(() => new Set(activePinPreferences.sessions), [activePinPreferences.sessions]);
|
||||
|
||||
const updatePins = useCallback((kind: "folders" | "sessions", key: string) => {
|
||||
setPinPreferences((current) => {
|
||||
const base = current.storageKey === pinStorageKey
|
||||
? current
|
||||
: { storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) };
|
||||
const values = new Set(base.sessions);
|
||||
if (kind === "sessions") {
|
||||
if (values.has(key)) values.delete(key);
|
||||
else values.add(key);
|
||||
}
|
||||
const next = kind === "folders"
|
||||
? { ...base, folders: toggleFolderPins(base.folders, key, directoryIdentities) }
|
||||
: { ...base, sessions: Array.from(values) };
|
||||
try {
|
||||
localStorage.setItem(pinStorageKey, JSON.stringify({ folders: next.folders, sessions: next.sessions }));
|
||||
} catch {
|
||||
// Pinning remains available for this run even when localStorage is unavailable.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [pinStorageKey, directoryIdentities]);
|
||||
|
||||
const orderedSessionGroups = useMemo(() => mergeSessionDirectoryGroups(groupedSessions, directoryIdentities).map(({ group, folderKey, items }) => {
|
||||
const orderedItems = [...items].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
});
|
||||
return {
|
||||
group,
|
||||
folderKey,
|
||||
items: orderedItems,
|
||||
newestAt: orderedItems.reduce((latest, item) => Math.max(latest, item.updatedAtMs || 0), 0),
|
||||
pinned: pinnedFolderSet.has(folderKey),
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
if (!sessionGroupByCwd) return 0;
|
||||
const pinnedOrder = Number(right.pinned) - Number(left.pinned);
|
||||
return pinnedOrder || right.newestAt - left.newestAt || left.group.localeCompare(right.group);
|
||||
}), [groupedSessions, directoryIdentities, pinnedFolderSet, pinnedSessionSet, sessionGroupByCwd]);
|
||||
|
||||
const orderedAllSessions = useMemo(() => [...filteredSessions].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
}), [filteredSessions, pinnedSessionSet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return;
|
||||
if (!orderedSessionGroups.some((group) => group.folderKey === selectedFolderKey)) {
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}
|
||||
}, [orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
|
||||
const displayedSessions = useMemo(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return orderedAllSessions;
|
||||
return sessionsForDirectoryGroup(orderedSessionGroups, selectedFolderKey);
|
||||
}, [orderedAllSessions, orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
const selectedVisibleCount = displayedSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = displayedSessions.length > 0 && selectedVisibleCount === displayedSessions.length;
|
||||
const visibleSelectionIsPartial = selectedVisibleCount > 0 && !allVisibleSelected;
|
||||
|
||||
const virtualSessionModel = useMemo(() => {
|
||||
const rows: SessionVirtualRow[] = [];
|
||||
let top = 0;
|
||||
displayedSessions.forEach((item) => {
|
||||
rows.push({
|
||||
kind: "session",
|
||||
key: `session:${item.id}`,
|
||||
top,
|
||||
height: SESSION_ROW_HEIGHT,
|
||||
item,
|
||||
});
|
||||
top += SESSION_ROW_HEIGHT;
|
||||
});
|
||||
return { rows, totalHeight: top };
|
||||
}, [displayedSessions]);
|
||||
|
||||
const visibleSessionRows = useMemo(() => {
|
||||
const viewportHeight = sessionScrollMetrics.viewportHeight || DEFAULT_SESSION_VIEWPORT_HEIGHT;
|
||||
const bodyScrollTop = Math.max(0, sessionScrollMetrics.scrollTop - SESSION_COLUMN_HEADER_HEIGHT);
|
||||
const firstVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop);
|
||||
const lastVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop + viewportHeight);
|
||||
const firstIndex = Math.max(0, firstVisibleIndex - SESSION_VIRTUAL_OVERSCAN_ROWS);
|
||||
const lastIndex = Math.min(
|
||||
virtualSessionModel.rows.length,
|
||||
lastVisibleIndex + SESSION_VIRTUAL_OVERSCAN_ROWS + 1,
|
||||
);
|
||||
return virtualSessionModel.rows.slice(firstIndex, lastIndex);
|
||||
}, [sessionScrollMetrics, virtualSessionModel]);
|
||||
|
||||
const syncSessionScrollMetrics = useCallback(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
setSessionScrollMetrics((current) => {
|
||||
const next = {
|
||||
scrollTop: element.scrollTop,
|
||||
viewportHeight: element.clientHeight,
|
||||
};
|
||||
return current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight
|
||||
? current
|
||||
: next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSessionScroll = useCallback(() => {
|
||||
syncSessionScrollMetrics();
|
||||
}, [syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
syncSessionScrollMetrics();
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return undefined;
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", syncSessionScrollMetrics);
|
||||
return () => window.removeEventListener("resize", syncSessionScrollMetrics);
|
||||
}
|
||||
const observer = new ResizeObserver(syncSessionScrollMetrics);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [active, syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
if (element.scrollTop > maxScrollTop) {
|
||||
element.scrollTop = maxScrollTop;
|
||||
syncSessionScrollMetrics();
|
||||
}
|
||||
}, [syncSessionScrollMetrics, virtualSessionModel.totalHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
element.scrollTop = 0;
|
||||
syncSessionScrollMetrics();
|
||||
}, [selectedFolderKey, sessionGroupByCwd, syncSessionScrollMetrics]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
@@ -387,14 +633,26 @@ export function SessionManagementPage({
|
||||
className={cx("cx-session-toggle", showInternalSessions && "cx-session-toggle--active")}
|
||||
checked={showInternalSessions}
|
||||
onCheckedChange={onShowInternalSessionsChange}
|
||||
title={copy.internalHint}
|
||||
label={copy.showInternal(sessionStatus?.subagentThreads ?? 0)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-button cx-session-button--secondary"
|
||||
onClick={() => onExportSessions(selectedSessionIds)}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy || selectedSessionIds.length === 0}
|
||||
title={copy.exportHint}
|
||||
aria-busy={sessionExportBusy}
|
||||
>
|
||||
{sessionExportBusy ? <Loader2 size={15} className="cx-session-spin" aria-hidden="true" /> : <Download size={15} strokeWidth={1.9} aria-hidden="true" />}
|
||||
{sessionExportBusy ? copy.exporting : copy.exportSelected}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-button cx-session-delete-trigger", selectedSessionIds.length > 0 ? "cx-session-button--danger" : "cx-session-button--secondary")}
|
||||
onClick={onOpenDeleteConfirm}
|
||||
disabled={loading || sessionDeleteBusy || selectedSessionIds.length === 0}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy || selectedSessionIds.length === 0}
|
||||
title={selectedSessionIds.length > 0 ? undefined : copy.deleteSelected}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
@@ -402,57 +660,112 @@ export function SessionManagementPage({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredSessions.length > 0 ? (
|
||||
<div className="cx-session-scroll" role="table" aria-label={copy.list}>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(filteredSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span>ID</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body">
|
||||
{groupedSessions.map(([group, items]) => {
|
||||
const projectSessions = allSessionsByCwd.get(group) || items;
|
||||
const selectedProjectCount = projectSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const projectSelected = projectSessions.length > 0 && selectedProjectCount === projectSessions.length;
|
||||
const projectPartiallySelected = selectedProjectCount > 0 && !projectSelected;
|
||||
const groupCountLabel = items.length === projectSessions.length
|
||||
? copy.projectCount(projectSessions.length, sessionPreviewTruncated)
|
||||
: copy.projectShown(items.length, projectSessions.length);
|
||||
return (
|
||||
<div className="cx-session-group" key={group}>
|
||||
{sessionGroupByCwd && (
|
||||
<label className={cx("cx-session-group-heading", projectSelected && "cx-session-group-heading--selected", projectPartiallySelected && "cx-session-group-heading--partial")}>
|
||||
<input
|
||||
className="cx-session-checkbox"
|
||||
type="checkbox"
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = projectPartiallySelected;
|
||||
}}
|
||||
checked={projectSelected}
|
||||
onChange={(event) => onSetSessionGroupSelected(projectSessions, event.target.checked)}
|
||||
aria-label={copy.selectProject(group, projectSessions.length)}
|
||||
/>
|
||||
<span title={group}>{compactPath(group, 96, isChinese ? "未记录路径" : "No path recorded")}</span>
|
||||
<em>{groupCountLabel}</em>
|
||||
</label>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<label className={cx("cx-session-row", item.needsSync && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")} key={item.id}>
|
||||
<div className={cx("cx-session-browser", !sessionGroupByCwd && "cx-session-browser--flat")}>
|
||||
{sessionGroupByCwd && (
|
||||
<aside className="cx-session-folder-tree" aria-label={copy.folders}>
|
||||
<div className="cx-session-tree-heading">
|
||||
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<FolderTree size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.folders}</span>
|
||||
</div>
|
||||
<div className="cx-session-tree-items" role="tree">
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === SESSION_ALL_FOLDERS_KEY}
|
||||
className={cx("cx-session-tree-all", selectedFolderKey === SESSION_ALL_FOLDERS_KEY && "cx-session-tree-item--active")}
|
||||
onClick={() => setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY)}
|
||||
>
|
||||
<History size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.allSessions}</span>
|
||||
<em>{filteredSessions.length}</em>
|
||||
</button>
|
||||
{orderedSessionGroups.map((folder) => (
|
||||
<div
|
||||
className={cx("cx-session-tree-item", selectedFolderKey === folder.folderKey && "cx-session-tree-item--active")}
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === folder.folderKey}
|
||||
key={folder.folderKey}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-tree-select"
|
||||
onClick={() => setSelectedFolderKey(folder.folderKey)}
|
||||
title={folder.group}
|
||||
>
|
||||
<Folder size={15} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span className="cx-session-folder-copy">
|
||||
<strong>{folderDisplayName(folder.group, isChinese ? "未记录路径" : "No path recorded")}</strong>
|
||||
<small>{compactPath(folder.group, 34, isChinese ? "未记录路径" : "No path recorded")}</small>
|
||||
</span>
|
||||
<em>{folder.items.length}</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", folder.pinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("folders", folder.items.find((item) => item.cwd)?.cwd || "")}
|
||||
aria-label={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
title={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
>
|
||||
<Pin size={13} strokeWidth={1.9} fill={folder.pinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="cx-session-results">
|
||||
{displayedSessions.length > 0 ? (
|
||||
<div
|
||||
ref={sessionScrollRef}
|
||||
className="cx-session-scroll"
|
||||
role="table"
|
||||
aria-label={copy.list}
|
||||
onScroll={handleSessionScroll}
|
||||
>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(displayedSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span className="cx-session-id-heading">ID</span>
|
||||
<span className="cx-session-actions-heading">{isChinese ? "操作" : "Actions"}</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body" style={{ height: virtualSessionModel.totalHeight }}>
|
||||
{visibleSessionRows.map((row) => {
|
||||
const { item } = row;
|
||||
const sessionPinned = pinnedSessionSet.has(item.id);
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"cx-session-row",
|
||||
"cx-session-virtual-item",
|
||||
item.needsSync && !item.isSubagent && "cx-session-row--needs-sync",
|
||||
selectedSessionSet.has(item.id) && "cx-session-row--selected",
|
||||
sessionPinned && "cx-session-row--pinned",
|
||||
)}
|
||||
key={row.key}
|
||||
role="row"
|
||||
style={{ top: row.top, height: row.height }}
|
||||
onClick={(event) => {
|
||||
if (!(event.target as HTMLElement).closest("button, input") && !loading && !sessionDeleteBusy) onToggleSessionSelected(item.id);
|
||||
}}
|
||||
>
|
||||
<span className="cx-session-select-box" title={copy.selectSession}>
|
||||
<input
|
||||
className="cx-session-checkbox"
|
||||
type="checkbox"
|
||||
checked={selectedSessionSet.has(item.id)}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
onChange={() => onToggleSessionSelected(item.id)}
|
||||
aria-label={`${copy.selectSession}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")} (#${shortId(item.id)})`}
|
||||
/>
|
||||
@@ -461,28 +774,49 @@ export function SessionManagementPage({
|
||||
<div className="cx-session-row-title">
|
||||
<strong title={item.title}>{item.title || (isChinese ? "未命名会话" : "Untitled session")}</strong>
|
||||
{item.archived && <span className="cx-session-state">{copy.archived}</span>}
|
||||
{item.isSubagent && <span className="cx-session-state">{copy.internal}</span>}
|
||||
{item.needsSync && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
|
||||
{item.isSubagent && <span className="cx-session-state" title={copy.internalHint}>{copy.internal}</span>}
|
||||
{item.needsSync && !item.isSubagent && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
|
||||
</div>
|
||||
{!sessionGroupByCwd && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
|
||||
{(!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
|
||||
</div>
|
||||
<span className="cx-session-meta cx-session-meta--time" title={item.updatedAtMs ? new Date(item.updatedAtMs).toLocaleString() : undefined}>{formatSessionTime(item.updatedAtMs, lang)}</span>
|
||||
<code className="cx-session-meta cx-session-meta--provider" title={item.modelProvider || undefined}>{item.modelProvider || copy.unknownProvider}</code>
|
||||
<span className="cx-session-meta cx-session-meta--model" title={item.model || undefined}>{item.model || copy.noModel}</span>
|
||||
<small className="cx-session-meta cx-session-meta--id" title={item.id}>#{shortId(item.id)}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="cx-session-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", sessionPinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("sessions", item.id)}
|
||||
aria-label={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
title={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
>
|
||||
<Pin size={14} strokeWidth={1.9} fill={sessionPinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-row-export"
|
||||
onClick={() => onExportSessions([item.id])}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy}
|
||||
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
|
||||
title={copy.exportOne}
|
||||
>
|
||||
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnostics.length ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useId, useState } from "react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Blocks,
|
||||
@@ -32,14 +31,14 @@ export type SkillsMcpPageProps = {
|
||||
noteBusyKey: string;
|
||||
importOpen: boolean;
|
||||
importPreview: SkillsMcpImportPreview | null;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
className?: string;
|
||||
onTabChange: (tab: SkillsMcpTab) => void;
|
||||
onLoad: MaybeAsyncAction;
|
||||
onOpenImportPreview: MaybeAsyncAction;
|
||||
onCloseImportPreview: () => void;
|
||||
onConfirmImport: MaybeAsyncAction;
|
||||
onInstallZip: (file?: File | null) => void | Promise<void>;
|
||||
onInstallZip: MaybeAsyncAction;
|
||||
onExport: (kind: SkillsMcpTab) => void | Promise<void>;
|
||||
onCheckUpdates: MaybeAsyncAction;
|
||||
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
@@ -63,7 +62,9 @@ function getCopy(lang: Lang) {
|
||||
description: "管理 Codex 当前可用的 Skills 与 MCP,导入已有内容、安装技能包并控制启用状态。",
|
||||
refresh: "刷新",
|
||||
importExisting: "导入已有",
|
||||
installZip: "从 ZIP 安装",
|
||||
installZip: "从 ZIP 导入",
|
||||
exportZip: "导出 ZIP",
|
||||
exportHint: "导出当前标签页的全部内容,可通过 ZIP 再次导入;MCP 包可能包含连接密钥,请妥善保存。",
|
||||
checkUpdates: "检查更新",
|
||||
loading: "正在读取本地 Skills / MCP...",
|
||||
mcpHelp: (count: number) => `当前共有 ${count} 个 MCP,启用后会写入 Codex config.toml。`,
|
||||
@@ -102,7 +103,9 @@ function getCopy(lang: Lang) {
|
||||
description: "Manage the Skills and MCP servers available to Codex, import existing items, install packages, and control their state.",
|
||||
refresh: "Refresh",
|
||||
importExisting: "Import existing",
|
||||
installZip: "Install ZIP",
|
||||
installZip: "Import ZIP",
|
||||
exportZip: "Export ZIP",
|
||||
exportHint: "Export all items in this tab for ZIP import. MCP archives may contain connection credentials; keep them private.",
|
||||
checkUpdates: "Check updates",
|
||||
loading: "Loading local Skills / MCP...",
|
||||
mcpHelp: (count: number) => `${count} MCP server(s). Enabling one writes it to Codex config.toml.`,
|
||||
@@ -311,7 +314,6 @@ export function SkillsMcpPage({
|
||||
noteBusyKey,
|
||||
importOpen,
|
||||
importPreview,
|
||||
zipInputRef,
|
||||
className,
|
||||
onTabChange,
|
||||
onLoad,
|
||||
@@ -319,6 +321,7 @@ export function SkillsMcpPage({
|
||||
onCloseImportPreview,
|
||||
onConfirmImport,
|
||||
onInstallZip,
|
||||
onExport,
|
||||
onCheckUpdates,
|
||||
onToggleSkill,
|
||||
onToggleMcp,
|
||||
@@ -350,9 +353,6 @@ export function SkillsMcpPage({
|
||||
setNoteTarget(null);
|
||||
}
|
||||
};
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onInstallZip(event.currentTarget.files?.[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cx("cx-skills-page", className)} aria-label={copy.title}>
|
||||
@@ -363,16 +363,6 @@ export function SkillsMcpPage({
|
||||
<span>{copy.description}</span>
|
||||
</div>
|
||||
<div className="cx-skills-actions">
|
||||
<input
|
||||
ref={zipInputRef}
|
||||
className="cx-skills-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={handleZipChange}
|
||||
disabled={anyBusy}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={actionBusy === "loadSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <RefreshCw />}
|
||||
@@ -392,7 +382,7 @@ export function SkillsMcpPage({
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={actionBusy === "installSkillZip" ? <Loader2 className="cx-skills-spin" /> : <Upload />}
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
onClick={() => run(onInstallZip)}
|
||||
disabled={anyBusy}
|
||||
>
|
||||
{copy.installZip}
|
||||
@@ -456,7 +446,12 @@ export function SkillsMcpPage({
|
||||
{activeTab === "mcp" ? <PlugZap size={17} aria-hidden="true" /> : <PackageOpen size={17} aria-hidden="true" />}
|
||||
<h3>{activeTab === "mcp" ? "MCP" : "Skills"}</h3>
|
||||
</div>
|
||||
<span>{copy.total(activeItems.length)}</span>
|
||||
<div className="cx-skills-list-tools">
|
||||
<span>{copy.total(activeItems.length)}</span>
|
||||
<Button variant="ghost" size="sm" icon={actionBusy === "exportSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <Download />}
|
||||
title={copy.exportHint} disabled={anyBusy || activeItems.length === 0}
|
||||
onClick={() => void onExport(activeTab)}>{copy.exportZip}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cx-skills-list">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { UsageDay, UsageModel, UsageRange, UsageStatistics } from "../usageTypes";
|
||||
import { initialUsageLoadState, sameUsageQuery, usageLoadReducer } from "../usageStatisticsState";
|
||||
import "../styles/usage-statistics.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
@@ -77,6 +78,8 @@ function copyFor(lang: Language) {
|
||||
noDataDescription: "当前范围内还没有可统计的会话记录。使用 Codex 后点击刷新,或试试更大的日期范围。",
|
||||
showAll: "查看全部时间", loading: "正在整理本地会话用量…",
|
||||
error: "暂时无法读取用量", stale: "刷新失败,下面保留上次读取的结果。",
|
||||
updatingResults: "正在更新用量,保留当前结果。", showingResults: "当前显示",
|
||||
loadingResults: "正在加载", previousResults: "下方暂时显示", failedResults: "未能加载",
|
||||
partial: "部分记录未计入", partialHint: "部分本地记录不完整,当前结果仅包含可读取的用量。",
|
||||
source: "本地会话记录", updated: "更新于", notes: "缓存输入已包含在输入 Token 中,推理 Token 已包含在输出 Token 中。历史记录缺失的用量无法补算。",
|
||||
scanned: "已检查", files: "份记录", filtered: "当前模型", activity: "使用概览",
|
||||
@@ -99,6 +102,8 @@ function copyFor(lang: Language) {
|
||||
noDataDescription: "No usage records in this range yet. Refresh after using Codex, or try a broader date range.",
|
||||
showAll: "View all time", loading: "Reading local session usage…",
|
||||
error: "Unable to load usage", stale: "Refresh failed. The last available results are shown below.",
|
||||
updatingResults: "Updating usage. Current results stay visible.", showingResults: "Showing",
|
||||
loadingResults: "Loading", previousResults: "Results below", failedResults: "Unable to load",
|
||||
partial: "Some records were excluded", partialHint: "Some local records are incomplete. These results include readable usage only.",
|
||||
source: "Local session records", updated: "Updated", notes: "Cached input is included in input tokens; reasoning is included in output tokens. Usage cannot be recovered from missing historical records.",
|
||||
scanned: "Checked", files: "records", filtered: "Selected model", activity: "Usage overview",
|
||||
@@ -236,27 +241,31 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
|
||||
const copy = copyFor(lang);
|
||||
const [range, setRange] = useState<UsageRange>("7d");
|
||||
const [model, setModel] = useState("");
|
||||
const [record, setRecord] = useState<{ key: string; data: UsageStatistics } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loadState, dispatch] = useReducer(usageLoadReducer, initialUsageLoadState);
|
||||
const requestRef = useRef(0);
|
||||
const previousDir = useRef(configDir);
|
||||
const queryKey = JSON.stringify([configDir, range, model]);
|
||||
const data = record?.key === queryKey ? record.data : null;
|
||||
const query = useMemo(() => ({ configDir, range, model }), [configDir, range, model]);
|
||||
const record = loadState.record?.query.configDir === configDir ? loadState.record : null;
|
||||
const data = record?.data ?? null;
|
||||
const currentRequest = sameUsageQuery(loadState.query, query);
|
||||
const busy = active && (!currentRequest || loadState.busy);
|
||||
const error = currentRequest ? loadState.error : "";
|
||||
const displayingPrevious = record !== null && !sameUsageQuery(record.query, query);
|
||||
const queryLabel = (value: typeof query) => `${copy.ranges[value.range]} · ${value.model ? modelLabel(value.model, lang) : copy.allModels}`;
|
||||
const statusText = displayingPrevious
|
||||
? `${error ? copy.failedResults : copy.loadingResults} ${queryLabel(query)} · ${copy.previousResults} ${queryLabel(record.query)}`
|
||||
: busy ? copy.updatingResults : `${copy.showingResults} ${queryLabel(query)}`;
|
||||
const load = useCallback(async (forceRefresh = false) => {
|
||||
if (!active) return;
|
||||
const request = ++requestRef.current;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
dispatch({ type: "start", requestId: request, query });
|
||||
try {
|
||||
const next = await invoke<UsageStatistics>("get_usage_statistics", { configDir, range, model: model || null, forceRefresh });
|
||||
if (request === requestRef.current) setRecord({ key: queryKey, data: next });
|
||||
dispatch({ type: "success", requestId: request, data: next });
|
||||
} catch (cause) {
|
||||
if (request === requestRef.current) setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
if (request === requestRef.current) setBusy(false);
|
||||
dispatch({ type: "failure", requestId: request, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
}, [active, configDir, range, model, queryKey]);
|
||||
}, [active, configDir, range, model, query]);
|
||||
useEffect(() => {
|
||||
if (previousDir.current !== configDir) {
|
||||
previousDir.current = configDir;
|
||||
@@ -266,10 +275,10 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => { requestRef.current += 1; };
|
||||
return () => { dispatch({ type: "cancel", requestId: ++requestRef.current }); };
|
||||
}, [configDir, model, load]);
|
||||
const modelOptions = useMemo(() => {
|
||||
const available = record?.key && JSON.parse(record.key)[0] === configDir ? record.data.availableModels : [];
|
||||
const available = record?.data.availableModels ?? [];
|
||||
return Array.from(new Set([...available, ...(model ? [model] : [])])).sort();
|
||||
}, [record, configDir, model]);
|
||||
const partial = data && (data.coverage.skippedFiles > 0 || data.coverage.truncated || data.coverage.warnings.length > 0);
|
||||
@@ -285,6 +294,10 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
|
||||
<div className="cx-usage-toolbar-actions"><label className="cx-usage-model-select"><span className="cx-usage-sr-only">{copy.model}</span><select value={model} onChange={(event) => setModel(event.currentTarget.value)} aria-label={copy.model}><option value="">{copy.allModels}</option>{modelOptions.map((value) => <option key={value} value={value}>{modelLabel(value, lang)}</option>)}</select></label>
|
||||
<button type="button" className="cx-page-button cx-page-button--secondary cx-usage-refresh" disabled={busy} onClick={() => void load(true)}><RefreshCw size={14} className={busy ? "cx-page-spin" : ""} aria-hidden="true" />{busy ? copy.refreshing : copy.refresh}</button>
|
||||
</div>
|
||||
<div className="cx-usage-query-status" role="status" aria-live="polite" aria-atomic="true">
|
||||
{busy && data && <Loader2 size={12} className="cx-page-spin" aria-hidden="true" />}
|
||||
<span>{data ? statusText : "\u00a0"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="cx-usage-notice cx-usage-notice--error" role="alert"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.stale}</p>}<p>{error}</p></div><button type="button" onClick={() => void load(true)} disabled={busy}>{copy.retry}</button></div>}
|
||||
{!data && !error && <div className="cx-usage-loading" role="status"><Loader2 size={27} className="cx-page-spin" aria-hidden="true" /><p>{copy.loading}</p><div className="cx-usage-loading-cards" aria-hidden="true"><span /><span /><span /></div></div>}
|
||||
@@ -293,7 +306,7 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
|
||||
{data && hasUsage && <>
|
||||
<section className="cx-usage-summary" aria-label={copy.activity}>
|
||||
<article className="cx-usage-total">
|
||||
<div className="cx-usage-total-heading"><span><Activity size={16} aria-hidden="true" />{copy.total}</span><span className="cx-usage-period-label">{copy.ranges[range]}</span></div>
|
||||
<div className="cx-usage-total-heading"><span><Activity size={16} aria-hidden="true" />{copy.total}</span><span className="cx-usage-period-label">{copy.ranges[record?.query.range ?? range]}</span></div>
|
||||
<div className="cx-usage-total-value"><TokenValue value={data.totals.totalTokens} lang={lang} /><span>{copy.tokens}</span></div>
|
||||
<div className="cx-usage-total-caption"><span>{exact(data.totals.totalTokens, lang)} {copy.tokens}</span><span>{copy.totalHint}</span></div>
|
||||
<div className="cx-usage-composition" aria-hidden="true"><span className="cx-usage-swatch--input" style={{ width: `${ratio(data.totals.inputTokens - data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--cache" style={{ width: `${ratio(data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--output" style={{ width: `${ratio(data.totals.outputTokens, data.totals.totalTokens)}%` }} /></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useId, useRef, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
SlidersHorizontal,
|
||||
Shuffle,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell } from "../components/ui";
|
||||
import { UsageStatisticsPage } from "./UsageStatisticsPage";
|
||||
import { ProviderFailoverPage } from "./ProviderFailoverPage";
|
||||
import "../styles/utility-pages.css";
|
||||
|
||||
export type UtilityLanguage = "zh" | "en";
|
||||
@@ -112,18 +114,27 @@ export type SettingsPageProps = {
|
||||
onRestartCodex: () => Promise<boolean>;
|
||||
recheckBusy?: boolean;
|
||||
restartBusy?: boolean;
|
||||
generalRequest?: number;
|
||||
configHealthStatus?: ReactNode;
|
||||
onChange?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
// PageTransition keeps the previous content during its exit animation. Context
|
||||
// still propagates the current activity state so requests stop immediately,
|
||||
// while the retained usage component keeps its filters between tabs.
|
||||
const SettingsUsageActiveContext = createContext(false);
|
||||
const SettingsFailoverActiveContext = createContext(false);
|
||||
|
||||
function SettingsUsagePanel({ lang, configDir }: Pick<SettingsPageProps, "lang" | "configDir">) {
|
||||
const active = useContext(SettingsUsageActiveContext);
|
||||
return <UsageStatisticsPage lang={lang} configDir={configDir} active={active} />;
|
||||
}
|
||||
|
||||
function SettingsFailoverPanel({ lang, configDir, onChange }: Pick<SettingsPageProps, "lang" | "configDir" | "onChange">) {
|
||||
const active = useContext(SettingsFailoverActiveContext);
|
||||
return <ProviderFailoverPage lang={lang} configDir={configDir} active={active} onChange={onChange} />;
|
||||
}
|
||||
|
||||
type SettingRowProps = {
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
@@ -156,9 +167,14 @@ export function SettingsPage({
|
||||
onRestartCodex,
|
||||
recheckBusy = false,
|
||||
restartBusy = false,
|
||||
generalRequest = 0,
|
||||
configHealthStatus,
|
||||
onChange,
|
||||
}: SettingsPageProps) {
|
||||
const [tab, setTab] = useState<"general" | "usage">("general");
|
||||
const [tab, setTab] = useState<"general" | "usage" | "failover">("general");
|
||||
useEffect(() => { setTab("general"); }, [generalRequest]);
|
||||
const [usageOpened, setUsageOpened] = useState(false);
|
||||
const [failoverOpened, setFailoverOpened] = useState(false);
|
||||
const tabId = useId();
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
@@ -174,19 +190,20 @@ export function SettingsPage({
|
||||
<section className="cx-utility cx-page cx-page--settings">
|
||||
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
|
||||
<div className="cx-settings-tabs" role="tablist" aria-label={lang === "zh" ? "设置页面" : "Settings pages"}>
|
||||
{(["general", "usage"] as const).map((value, index) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : BarChart3;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); };
|
||||
{(["general", "usage", "failover"] as const).map((value, index, tabs) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : value === "usage" ? BarChart3 : Shuffle;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); if (value === "failover") setFailoverOpened(true); };
|
||||
return <button key={value} ref={(element) => { tabRefs.current[index] = element; }} type="button" role="tab" id={`${tabId}-${value}-tab`} aria-controls={`${tabId}-${value}-panel`} aria-selected={tab === value} tabIndex={tab === value ? 0 : -1} className="cx-settings-tab" onClick={select} onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||||
tabRefs.current[next]?.click();
|
||||
tabRefs.current[next]?.focus();
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : (lang === "zh" ? "用量统计" : "Usage statistics")}</button>;
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : value === "usage" ? (lang === "zh" ? "用量统计" : "Usage statistics") : (lang === "zh" ? "路由与故障转移" : "Routing & failover")}</button>;
|
||||
})}
|
||||
</div>
|
||||
<SettingsUsageActiveContext.Provider value={active && tab === "usage"}>
|
||||
<SettingsFailoverActiveContext.Provider value={active && tab === "failover"}>
|
||||
<PageTransition pageKey={`settings:${tab}`}>
|
||||
<div className="cx-settings-panel cx-page-settings-list" role="tabpanel" id={`${tabId}-general-panel`} aria-labelledby={`${tabId}-general-tab`} hidden={tab !== "general"}>
|
||||
<SettingRow
|
||||
@@ -227,6 +244,8 @@ export function SettingsPage({
|
||||
title={copy.recheckTitle}
|
||||
description={copy.recheckDescription}
|
||||
action={(
|
||||
<div className="cx-settings-check-actions">
|
||||
{configHealthStatus}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
@@ -236,6 +255,7 @@ export function SettingsPage({
|
||||
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
|
||||
{copy.recheckLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -260,7 +280,11 @@ export function SettingsPage({
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-usage-panel`} aria-labelledby={`${tabId}-usage-tab`} hidden={tab !== "usage"}>
|
||||
{usageOpened && <SettingsUsagePanel lang={lang} configDir={configDir} />}
|
||||
</div>
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-failover-panel`} aria-labelledby={`${tabId}-failover-tab`} hidden={tab !== "failover"}>
|
||||
{failoverOpened && <SettingsFailoverPanel lang={lang} configDir={configDir} onChange={onChange} />}
|
||||
</div>
|
||||
</PageTransition>
|
||||
</SettingsFailoverActiveContext.Provider>
|
||||
</SettingsUsageActiveContext.Provider>
|
||||
|
||||
<ModalShell
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ProviderPresetChoice } from "./components/ProviderPresetPicker";
|
||||
import type { ProviderModelMapping, SavedProvider } from "./types";
|
||||
|
||||
export type ProviderPresetVariant = {
|
||||
id: string;
|
||||
label: string;
|
||||
labelEn: string;
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
models: readonly ProviderModelMapping[];
|
||||
note: string;
|
||||
noteEn: string;
|
||||
};
|
||||
|
||||
export type ProviderPreset = ProviderPresetChoice & {
|
||||
variants: readonly ProviderPresetVariant[];
|
||||
sources?: readonly string[];
|
||||
};
|
||||
|
||||
const model = (id: string, displayName: string, contextWindow: number | null): ProviderModelMapping => ({ model: id, displayName, contextWindow });
|
||||
const deepseekModels = [model("deepseek-v4-flash", "DeepSeek V4 Flash", 1048576), model("deepseek-v4-pro", "DeepSeek V4 Pro", 1048576)];
|
||||
const minimaxModels = [model("MiniMax-M3", "MiniMax M3", 1000000), model("MiniMax-M2.7", "MiniMax M2.7", 204800)];
|
||||
const mimoModels = [model("mimo-v2.5-pro", "MiMo V2.5 Pro", 1048576), model("mimo-v2.5", "MiMo V2.5", 1048576)];
|
||||
|
||||
// Reviewed against vendor documentation on 2026-09-09. Only native Responses
|
||||
// endpoints belong here; Chat/Anthropic-only plans need a separate adapter.
|
||||
export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
|
||||
{ id: "custom", name: "自定义配置", nameEn: "Custom", brand: "custom", subtitle: "自行填写 API 配置", subtitleEn: "Your own API settings", variants: [] },
|
||||
{ id: "official", name: "OpenAI Official", brand: "openai", subtitle: "ChatGPT 账号登录", subtitleEn: "Sign in with ChatGPT", variants: [] },
|
||||
{
|
||||
id: "deepseek", name: "DeepSeek", brand: "deepseek", subtitle: "V4 Flash / Pro",
|
||||
variants: [{ id: "default", label: "开放平台", labelEn: "API platform", providerName: "DeepSeek", baseUrl: "https://api.deepseek.com", models: deepseekModels,
|
||||
note: "使用 DeepSeek 开放平台的 API Key。", noteEn: "Use an API key from the DeepSeek platform." }],
|
||||
sources: ["https://api-docs.deepseek.com/quick_start/agent_integrations/codex"],
|
||||
},
|
||||
{
|
||||
id: "minimax", name: "MiniMax", brand: "minimax", subtitle: "M3 / M2.7 · 国内与国际", subtitleEn: "M3 / M2.7 · China & global",
|
||||
variants: [
|
||||
{ id: "cn", label: "国内", labelEn: "China", providerName: "MiniMax 国内", baseUrl: "https://api.minimax.cn/v1", models: minimaxModels,
|
||||
note: "使用 MiniMax 国内平台的 API Key;请确认账号已开通所选模型。", noteEn: "Use a MiniMax China API key with access to the selected model." },
|
||||
{ id: "global", label: "国际", labelEn: "Global", providerName: "MiniMax 国际", baseUrl: "https://api.minimax.io/v1", models: minimaxModels,
|
||||
note: "使用 MiniMax 国际平台的 API Key;请确认账号已开通所选模型。", noteEn: "Use a MiniMax global API key with access to the selected model." },
|
||||
],
|
||||
sources: ["https://platform.minimaxi.com/docs/token-plan/codex", "https://platform.minimax.io/docs/token-plan/codex", "https://platform.minimax.io/docs/api-reference/responses-create"],
|
||||
},
|
||||
{
|
||||
id: "mimo", name: "小米 MiMo", nameEn: "Xiaomi MiMo", brand: "mimo", subtitle: "V2.5 Pro / V2.5",
|
||||
variants: [
|
||||
{ id: "api", label: "按量付费", labelEn: "Pay as you go", providerName: "小米 MiMo", baseUrl: "https://api.xiaomimimo.com/v1", models: mimoModels,
|
||||
note: "使用按量付费的 sk- 开头 API Key。", noteEn: "Use a pay-as-you-go API key beginning with sk-." },
|
||||
{ id: "token-plan", label: "Token Plan 套餐", labelEn: "Token Plan", providerName: "小米 MiMo Token Plan", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mimoModels,
|
||||
note: "使用 Token Plan 套餐的 tp- 开头 API Key,套餐与按量付费地址不同。", noteEn: "Use a Token Plan key beginning with tp-. This plan has its own API endpoint." },
|
||||
],
|
||||
sources: ["https://mimo.mi.com/docs/zh-CN/tokenplan/integration/codex-configuration"],
|
||||
},
|
||||
{
|
||||
id: "kimi", name: "Kimi", brand: "kimi", subtitle: "K3 · 开放平台", subtitleEn: "K3 · API platform",
|
||||
variants: [{ id: "default", label: "开放平台", labelEn: "API platform", providerName: "Kimi", baseUrl: "https://api.moonshot.cn/v1", models: [model("kimi-k3", "Kimi K3", 1048576)],
|
||||
note: "使用 Kimi 开放平台的 API Key,不适用于 Kimi Code 订阅密钥。", noteEn: "Use a Kimi API platform key. Kimi Code subscription keys use a different service." }],
|
||||
sources: ["https://platform.kimi.com/docs/guide/codex-kimi", "https://platform.kimi.com/docs/api/responses"],
|
||||
},
|
||||
{
|
||||
id: "glm", name: "智谱 GLM", nameEn: "Zhipu GLM", brand: "glm", subtitle: "GLM Coding Plan",
|
||||
variants: [{ id: "cn", label: "Coding Plan", labelEn: "Coding Plan", providerName: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/v1", models: [model("glm-5.3", "GLM-5.3", 1048576), model("glm-5-turbo", "GLM-5 Turbo", 204800)],
|
||||
note: "使用对应 GLM Coding Plan 套餐的 API Key;团队套餐请使用团队密钥。", noteEn: "Use the API key for your GLM Coding Plan. Team plans require their team key." }],
|
||||
sources: ["https://docs.bigmodel.cn/cn/coding-plan/tool/codex"],
|
||||
},
|
||||
{
|
||||
id: "qwen", name: "阿里千问", nameEn: "Alibaba Qwen", brand: "qwen", subtitle: "Qwen3.8 Max / Flash",
|
||||
variants: [{ id: "beijing", label: "北京地域", labelEn: "Beijing region", providerName: "阿里千问", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: [model("qwen3.8-max", "Qwen3.8 Max", null), model("qwen3.8-flash", "Qwen3.8 Flash", null)],
|
||||
note: "使用阿里云百炼北京地域的普通 API Key,不适用于 Coding Plan 专属密钥。", noteEn: "Use a regular Model Studio API key for Beijing. Coding Plan keys use a different endpoint." }],
|
||||
sources: ["https://help.aliyun.com/zh/model-studio/compatibility-with-openai-responses-api"],
|
||||
},
|
||||
];
|
||||
|
||||
export function getProviderPreset(id: string): ProviderPreset | undefined {
|
||||
return PROVIDER_PRESETS.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
export function getProviderPresetVariant(presetId: string, variantId: string): ProviderPresetVariant | undefined {
|
||||
const preset = getProviderPreset(presetId);
|
||||
return preset?.variants.find((variant) => variant.id === variantId) ?? preset?.variants[0];
|
||||
}
|
||||
|
||||
export function createPresetProvider(presetId: string, variantId: string, inheritedToml = ""): SavedProvider | null {
|
||||
const variant = getProviderPresetVariant(presetId, variantId);
|
||||
if (!variant) return null;
|
||||
return {
|
||||
id: `${presetId}-${variant.id}`,
|
||||
providerName: variant.providerName,
|
||||
baseUrl: variant.baseUrl,
|
||||
model: variant.models[0].model,
|
||||
apiKey: "",
|
||||
tomlConfig: inheritedToml,
|
||||
wireApi: "responses",
|
||||
requiresOpenaiAuth: false,
|
||||
modelMappings: variant.models.map((entry) => ({ ...entry })),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,33 @@ type OrderedProviderRow = {
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
type IdentifiedProviderRow = { source: string; id: string };
|
||||
|
||||
export function providerRowKey(row: IdentifiedProviderRow): string {
|
||||
return `${row.source}:${row.id}`;
|
||||
}
|
||||
|
||||
export function applyProviderRowOrder<Row extends IdentifiedProviderRow>(rows: readonly Row[], order: readonly string[]): Row[] {
|
||||
const remaining = new Map(rows.map((row) => [providerRowKey(row), row]));
|
||||
const sorted: Row[] = [];
|
||||
for (const key of order) {
|
||||
const row = remaining.get(key);
|
||||
if (row) {
|
||||
sorted.push(row);
|
||||
remaining.delete(key);
|
||||
}
|
||||
}
|
||||
return [...sorted, ...remaining.values()];
|
||||
}
|
||||
|
||||
export function moveProviderRow(order: readonly string[], source: string, target: string, position: "before" | "after"): string[] {
|
||||
if (source === target || !order.includes(source) || !order.includes(target)) return [...order];
|
||||
const next = order.filter((key) => key !== source);
|
||||
const targetIndex = next.indexOf(target);
|
||||
next.splice(targetIndex + (position === "after" ? 1 : 0), 0, source);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function orderProviderRows<
|
||||
Official extends OrderedProviderRow,
|
||||
Detected extends OrderedProviderRow,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Parameter ranges and save/reset semantics adapted from CC Switch (MIT).
|
||||
// Copyright (c) 2025 Jason Young. See THIRD_PARTY_NOTICES.md.
|
||||
export const routingFields = [
|
||||
{ key: "maxRetries", min: 0, max: 10, value: 3, group: "retry", zh: "最大重试次数", en: "Maximum retries", hintZh: "失败后最多再尝试几家供应商,0 表示不重试。", hintEn: "Additional providers to try after a failure. 0 disables retries." },
|
||||
{ key: "circuitFailureThreshold", min: 1, max: 20, value: 4, group: "retry", zh: "连续失败阈值", en: "Consecutive failures", hintZh: "连续失败达到此次数后,暂时跳过该供应商。", hintEn: "Temporarily skip a provider after this many consecutive failures." },
|
||||
{ key: "streamingFirstByteTimeout", min: 1, max: 120, value: 60, group: "timeout", zh: "流式首字节超时", en: "First response timeout", hintZh: "等待开始返回内容的时间,单位为秒。", hintEn: "Seconds to wait for the first response data." },
|
||||
{ key: "streamingIdleTimeout", min: 0, max: 600, value: 120, group: "timeout", zh: "流式静默超时", en: "Stream idle timeout", hintZh: "回复过程中等待下一段内容的秒数;0 表示不限制。", hintEn: "Seconds between response chunks. 0 disables this timeout." },
|
||||
{ key: "nonStreamingTimeout", min: 60, max: 1200, value: 600, group: "timeout", zh: "非流式总超时", en: "Non-streaming timeout", hintZh: "等待完整回复的最长时间,单位为秒。", hintEn: "Total seconds to wait for a complete non-streaming response." },
|
||||
{ key: "circuitSuccessThreshold", min: 1, max: 10, value: 2, group: "recovery", zh: "恢复成功次数", en: "Recovery successes", hintZh: "试探恢复时,连续成功几次后恢复正常。", hintEn: "Successful trial requests required to restore normal routing." },
|
||||
{ key: "circuitTimeoutSeconds", min: 0, max: 300, value: 60, group: "recovery", zh: "恢复等待时间", en: "Recovery wait", hintZh: "暂时跳过后,隔多少秒再尝试。", hintEn: "Seconds to wait before trying an unavailable provider again." },
|
||||
{ key: "circuitErrorRateThreshold", min: 0, max: 100, value: 60, group: "recovery", zh: "错误率阈值 (%)", en: "Error rate threshold (%)", hintZh: "达到此比例时,也会暂时跳过供应商。", hintEn: "Also skip a provider when its failure rate reaches this percentage." },
|
||||
{ key: "circuitMinRequests", min: 5, max: 100, value: 10, group: "recovery", zh: "最小请求数", en: "Minimum requests", hintZh: "累计达到此请求数后,才开始判断错误率。", hintEn: "Requests required before evaluating the error rate." },
|
||||
] as const;
|
||||
|
||||
export type RoutingField = typeof routingFields[number]["key"];
|
||||
export type RoutingSettings = Record<RoutingField, number> & {
|
||||
version: number; routerEnabled: boolean; takeoverEnabled: boolean; autoFailoverEnabled: boolean;
|
||||
listenAddress: string; listenPort: number; providerIds: string[];
|
||||
};
|
||||
export type RoutingDraft = Omit<RoutingSettings, RoutingField | "listenPort"> & Record<RoutingField | "listenPort", string>;
|
||||
|
||||
export function routingDraft(settings: RoutingSettings): RoutingDraft {
|
||||
const values = Object.fromEntries(routingFields.map(({ key }) => [key, String(key === "circuitErrorRateThreshold" ? Number((settings[key] * 100).toFixed(6)) : settings[key])])) as Record<RoutingField, string>;
|
||||
return { ...settings, ...values, listenPort: String(settings.listenPort), providerIds: [...settings.providerIds] };
|
||||
}
|
||||
|
||||
export function sameRoutingDraft(a: RoutingDraft, b: RoutingDraft): boolean {
|
||||
return a.routerEnabled === b.routerEnabled && a.takeoverEnabled === b.takeoverEnabled
|
||||
&& a.autoFailoverEnabled === b.autoFailoverEnabled && a.listenAddress === b.listenAddress && a.listenPort === b.listenPort
|
||||
&& routingFields.every(({ key }) => a[key] === b[key])
|
||||
&& a.providerIds.length === b.providerIds.length && a.providerIds.every((id, index) => id === b.providerIds[index]);
|
||||
}
|
||||
|
||||
export function parseRoutingDraft(draft: RoutingDraft): { settings: RoutingSettings | null; errors: Partial<Record<RoutingField | "listenAddress" | "listenPort" | "providerIds", string>> } {
|
||||
const errors: ReturnType<typeof parseRoutingDraft>["errors"] = {};
|
||||
const numbers = {} as Record<RoutingField, number>;
|
||||
for (const field of routingFields) {
|
||||
const text = draft[field.key].trim();
|
||||
const pattern = field.key === "circuitErrorRateThreshold" ? /^\d+(?:\.\d+)?$/ : /^\d+$/;
|
||||
const value = Number(text);
|
||||
if (!pattern.test(text) || !Number.isFinite(value) || value < field.min || value > field.max) errors[field.key] = `${field.min}–${field.max}`;
|
||||
numbers[field.key] = field.key === "circuitErrorRateThreshold" ? value / 100 : value;
|
||||
}
|
||||
let address = draft.listenAddress.trim();
|
||||
if (address.toLowerCase() === "localhost") address = "127.0.0.1";
|
||||
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(address) && address.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part);
|
||||
let ipv6 = false;
|
||||
if (address.includes(":")) {
|
||||
try { ipv6 = new URL(`http://[${address}]/`).hostname.startsWith("["); } catch { /* invalid literal */ }
|
||||
}
|
||||
if (!ipv4 && !ipv6) errors.listenAddress = "IPv4 / IPv6 / localhost";
|
||||
const port = Number(draft.listenPort);
|
||||
if (!/^\d+$/.test(draft.listenPort.trim()) || port < 1024 || port > 65535) errors.listenPort = "1024–65535";
|
||||
if (draft.providerIds.length > 64 || new Set(draft.providerIds).size !== draft.providerIds.length) errors.providerIds = "1–64";
|
||||
return { errors, settings: Object.keys(errors).length ? null : {
|
||||
...draft, ...numbers, version: 2, listenAddress: address, listenPort: port, providerIds: [...draft.providerIds],
|
||||
} };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export const SESSION_FOLDER_UNKNOWN_KEY = "__codexx_no_workspace__";
|
||||
const SESSION_PINS_STORAGE_PREFIX = "codexx.sessionPins.v2";
|
||||
|
||||
export type SessionDirectoryIdentities = Readonly<Record<string, string>>;
|
||||
export type SessionPinPreferences = { folders: string[]; sessions: string[] };
|
||||
export type SessionDirectoryGroup<T> = { group: string; folderKey: string; items: T[] };
|
||||
|
||||
/** Only the backend can confirm that differently spelled paths name one directory. */
|
||||
export function sessionDirectoryKey(
|
||||
path?: string | null,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): string {
|
||||
if (path == null || path === "") return SESSION_FOLDER_UNKNOWN_KEY;
|
||||
if (identities && Object.prototype.hasOwnProperty.call(identities, path)) {
|
||||
return identities[path];
|
||||
}
|
||||
// Missing/offline directories keep their exact spelling. Case, backslashes,
|
||||
// roots and spaces can all distinguish legitimate paths on some filesystems.
|
||||
return `path:${path}`;
|
||||
}
|
||||
|
||||
export function sessionPinStorageKey(codexDir: string, verifiedScope?: string | null) {
|
||||
// v1 discarded case and cannot safely assign its pins to a particular home.
|
||||
// A snapshot's file ID alone is not a permanent home identity after deletion.
|
||||
return `${SESSION_PINS_STORAGE_PREFIX}:${encodeURIComponent(verifiedScope || sessionDirectoryKey(codexDir))}`;
|
||||
}
|
||||
|
||||
export function pinnedSessionDirectoryKeys(folders: readonly string[], identities?: SessionDirectoryIdentities): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const stored of folders) {
|
||||
if (stored === SESSION_FOLDER_UNKNOWN_KEY) keys.add(stored);
|
||||
else if (stored.startsWith("path:")) keys.add(sessionDirectoryKey(stored.slice(5), identities));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Persist original paths so offline/recreated folders retain their pins. */
|
||||
export function toggleFolderPins(
|
||||
folders: readonly string[],
|
||||
path?: string | null,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): string[] {
|
||||
const currentKey = sessionDirectoryKey(path, identities);
|
||||
const belongsToCurrentFolder = (stored: string) => pinnedSessionDirectoryKeys([stored], identities).has(currentKey);
|
||||
if (folders.some(belongsToCurrentFolder)) return folders.filter((stored) => !belongsToCurrentFolder(stored));
|
||||
return [...new Set([...folders, sessionDirectoryKey(path)])];
|
||||
}
|
||||
|
||||
export function readSessionPinPreferences(
|
||||
storageKey: string,
|
||||
storage: Pick<Storage, "getItem">,
|
||||
): SessionPinPreferences {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "{}") as Partial<SessionPinPreferences>;
|
||||
return {
|
||||
folders: Array.isArray(parsed.folders) ? parsed.folders.filter((value): value is string => typeof value === "string") : [],
|
||||
sessions: Array.isArray(parsed.sessions) ? parsed.sessions.filter((value): value is string => typeof value === "string") : [],
|
||||
};
|
||||
} catch {
|
||||
return { folders: [], sessions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSessionDirectoryGroups<T extends { cwd?: string | null }>(
|
||||
groups: ReadonlyArray<readonly [string, readonly T[]]>,
|
||||
identities?: SessionDirectoryIdentities,
|
||||
): SessionDirectoryGroup<T>[] {
|
||||
const result = new Map<string, SessionDirectoryGroup<T>>();
|
||||
for (const [group, items] of groups) {
|
||||
const folderKey = sessionDirectoryKey(items.find((item) => item.cwd)?.cwd, identities);
|
||||
const existing = result.get(folderKey);
|
||||
if (existing) existing.items.push(...items);
|
||||
else result.set(folderKey, { group, folderKey, items: [...items] });
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
export function sessionsForDirectoryGroup<T>(groups: ReadonlyArray<SessionDirectoryGroup<T>>, key: string): T[] {
|
||||
return groups.find((group) => group.folderKey === key)?.items || [];
|
||||
}
|
||||
@@ -224,6 +224,12 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.cx-update-notes .cx-update-log-path {
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@keyframes cx-update-progress-indeterminate {
|
||||
from { transform: translateX(-110%); }
|
||||
to { transform: translateX(300%); }
|
||||
@@ -250,6 +256,61 @@
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.cx-startup-path-control--manual {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cx-startup-config-health {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ui-border-subtle);
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ui-border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--ui-text-muted);
|
||||
background: var(--ui-surface-elevated);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice > svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice strong {
|
||||
color: var(--ui-text);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice strong + p {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice--error {
|
||||
border-color: color-mix(in srgb, var(--ui-warning) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--ui-warning) 8%, transparent);
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice--error > svg {
|
||||
color: var(--ui-warning);
|
||||
}
|
||||
|
||||
.cx-startup-diagnostics-notice + .cx-startup-checks {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cx-startup-path-control label {
|
||||
padding-left: 5px;
|
||||
color: #777d86;
|
||||
@@ -340,6 +401,7 @@
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.cx-startup-path-control { grid-template-columns: 1fr auto; }
|
||||
.cx-startup-path-control--manual { grid-template-columns: minmax(0, 1fr); }
|
||||
.cx-startup-path-control label { grid-column: 1 / -1; }
|
||||
.cx-startup-checks { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
.cx-config-health,
|
||||
.cx-settings-check-actions,
|
||||
.cx-config-health-toast {
|
||||
--cx-health-surface: #fff;
|
||||
--cx-health-text: #303640;
|
||||
--cx-health-muted: #6b7280;
|
||||
--cx-health-border: #e3e6eb;
|
||||
--cx-health-blue: #2563eb;
|
||||
--cx-health-blue-soft: #eef4ff;
|
||||
--cx-health-warning: #976216;
|
||||
--cx-health-warning-soft: #fff8e9;
|
||||
--cx-health-warning-border: #efdab2;
|
||||
--cx-health-success: #17845d;
|
||||
--cx-health-success-soft: #edf8f2;
|
||||
}
|
||||
|
||||
.cx-config-health,
|
||||
.cx-config-health-toast { color: var(--cx-health-text); background: var(--cx-health-surface); }
|
||||
.cx-settings-check-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.cx-config-health {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--cx-health-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 12px rgb(27 35 48 / 3.5%);
|
||||
}
|
||||
|
||||
.cx-config-health-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cx-config-health-icon {
|
||||
display: grid;
|
||||
flex: 0 0 38px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--cx-health-blue) 16%, var(--cx-health-border));
|
||||
border-radius: 8px;
|
||||
color: var(--cx-health-blue);
|
||||
background: var(--cx-health-blue-soft);
|
||||
}
|
||||
|
||||
.cx-config-health-heading-copy { flex: 1; min-width: 0; }
|
||||
.cx-config-health h3 { margin: 0; font-size: 14px; font-weight: 680; line-height: 1.4; }
|
||||
.cx-config-health-heading-copy p,
|
||||
.cx-config-health-note,
|
||||
.cx-config-health-error { margin: 5px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-config-health-status { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; padding: 5px 9px; border-radius: 6px; color: var(--cx-health-muted); background: var(--cx-health-blue-soft); font-size: 12px; font-weight: 600; }
|
||||
.cx-config-health-status--warning { color: var(--cx-health-warning); background: var(--cx-health-warning-soft); }
|
||||
.cx-config-health-status--success { color: var(--cx-health-success); background: var(--cx-health-success-soft); }
|
||||
.cx-config-health-error { margin-top: 14px; color: var(--cx-health-warning); }
|
||||
.cx-config-health-details { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cx-health-border); }
|
||||
.cx-config-health-issues { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.cx-config-health-issues > li { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
.cx-config-health-issues > li > svg { flex-shrink: 0; margin-top: 3px; color: var(--cx-health-warning); }
|
||||
.cx-config-health-issues strong { font-size: 13px; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.cx-config-health-issues p { margin: 3px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-config-health-plan { margin-top: 13px; padding: 11px 13px; border-radius: 7px; background: var(--cx-health-blue-soft); font-size: 12px; line-height: 1.7; }
|
||||
.cx-config-health-plan strong { font-weight: 600; }
|
||||
.cx-config-health-plan ul { display: grid; gap: 3px; margin: 5px 0 0; padding-left: 18px; overflow-wrap: anywhere; }
|
||||
.cx-config-health-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 14px; }
|
||||
.cx-config-health-footer > p { flex: 1 1 220px; margin: 0; }
|
||||
.cx-config-health-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.cx-config-health-toast {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
inset-inline: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow: auto;
|
||||
margin-inline: auto;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--cx-health-warning-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgb(25 32 44 / 15%), 0 2px 7px rgb(25 32 44 / 8%);
|
||||
pointer-events: auto;
|
||||
animation: cx-config-health-enter 180ms ease-out both;
|
||||
}
|
||||
|
||||
.cx-app-shell--mac .cx-config-health-toast { top: 52px; }
|
||||
.cx-config-health-toast-icon { display: grid; flex: 0 0 34px; width: 34px; height: 34px; place-items: center; border-radius: 9px; color: var(--cx-health-warning); background: var(--cx-health-warning-soft); }
|
||||
.cx-config-health-toast-content { min-width: 0; flex: 1; }
|
||||
.cx-config-health-toast-content strong { display: block; color: inherit; font-size: 14px; font-weight: 650; line-height: 1.5; }
|
||||
.cx-config-health-toast-content p { margin: 5px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-config-health-toast-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
|
||||
.cx-config-health-toast-content p.cx-config-health-toast-plan { margin-top: 9px; padding: 8px 10px; border-radius: 6px; color: var(--cx-health-text); background: var(--cx-health-blue-soft); }
|
||||
.cx-config-health-toast-content p.cx-config-health-toast-hint { margin-top: 10px; font-size: 11px; }
|
||||
.cx-config-health-toast-close { display: grid; flex: 0 0 25px; width: 25px; height: 25px; place-items: center; padding: 0; border: 0; border-radius: 5px; color: var(--cx-health-muted); background: transparent; }
|
||||
.cx-config-health-toast-close:hover:not(:disabled) { color: var(--cx-health-text); background: var(--cx-health-blue-soft); }
|
||||
.cx-config-health-toast-close:focus-visible { outline: 2px solid var(--cx-health-blue); outline-offset: 2px; }
|
||||
.cx-config-health-spinner { animation: cx-config-health-spin 900ms linear infinite; }
|
||||
|
||||
:root[data-theme="dark"] .cx-config-health,
|
||||
:root[data-theme="dark"] .cx-settings-check-actions,
|
||||
:root[data-theme="dark"] .cx-config-health-toast {
|
||||
--cx-health-surface: #30343c;
|
||||
--cx-health-text: #e5e7eb;
|
||||
--cx-health-muted: #acb2be;
|
||||
--cx-health-border: #454b56;
|
||||
--cx-health-blue: #91b8fa;
|
||||
--cx-health-blue-soft: #2b3d59;
|
||||
--cx-health-warning: #ebc083;
|
||||
--cx-health-warning-soft: #443c30;
|
||||
--cx-health-warning-border: #6b573a;
|
||||
--cx-health-success: #80d4af;
|
||||
--cx-health-success-soft: #273f38;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.cx-config-health-heading { flex-wrap: wrap; gap: 10px; }
|
||||
.cx-config-health-heading > .cx-config-health-status { margin-left: 48px; }
|
||||
.cx-config-health-heading-copy { flex-basis: calc(100% - 48px); }
|
||||
.cx-config-health-actions { width: 100%; }
|
||||
}
|
||||
|
||||
@keyframes cx-config-health-enter { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes cx-config-health-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .cx-config-health-toast, .cx-config-health-spinner { animation: none; } }
|
||||
@@ -603,10 +603,56 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-tree,
|
||||
:root[data-theme="dark"] .cx-session-tree-heading {
|
||||
color: var(--cx-dark-muted);
|
||||
background: var(--cx-dark-surface-raised);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-results {
|
||||
background: var(--cx-dark-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-all,
|
||||
:root[data-theme="dark"] .cx-session-tree-item {
|
||||
color: var(--cx-dark-muted);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-all:hover,
|
||||
:root[data-theme="dark"] .cx-session-tree-item:hover {
|
||||
color: var(--cx-dark-text);
|
||||
background: var(--cx-dark-surface-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-item--active {
|
||||
color: var(--cx-dark-blue);
|
||||
background: var(--cx-dark-blue-surface);
|
||||
border-color: #5879aa;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-tree-item--active .cx-session-folder-copy strong {
|
||||
color: var(--cx-dark-blue);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-group + .cx-session-group {
|
||||
border-top-color: var(--cx-dark-surface-recessed);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-group-heading--selected,
|
||||
:root[data-theme="dark"] .cx-session-group-heading--partial {
|
||||
color: var(--cx-dark-blue);
|
||||
background: var(--cx-dark-blue-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-copy strong {
|
||||
color: var(--cx-dark-text);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-folder-copy small {
|
||||
color: var(--cx-dark-muted);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-session-row {
|
||||
background: var(--cx-dark-surface);
|
||||
border-color: var(--cx-dark-border);
|
||||
@@ -830,6 +876,43 @@
|
||||
border-right-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-summary > div {
|
||||
background: var(--cx-dark-surface-raised);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-summary strong {
|
||||
color: #9fc2ff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-list {
|
||||
border-color: var(--cx-dark-border);
|
||||
background: var(--cx-dark-surface);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-section-head strong,
|
||||
:root[data-theme="dark"] .cx-skills-import-row strong {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-summary span,
|
||||
:root[data-theme="dark"] .cx-skills-import-section-head span,
|
||||
:root[data-theme="dark"] .cx-skills-import-list > p,
|
||||
:root[data-theme="dark"] .cx-skills-import-row span,
|
||||
:root[data-theme="dark"] .cx-skills-import-row em {
|
||||
color: #b6bac2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-warnings {
|
||||
background: #3c352b;
|
||||
border-color: #655238;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-import-warnings > strong,
|
||||
:root[data-theme="dark"] .cx-skills-import-warnings p {
|
||||
color: #e8c58d;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-prompts-mode-title strong {
|
||||
color: #d8dbe0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
.cx-official-account {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cx-official-account-badge {
|
||||
--cx-account-text: #747b85;
|
||||
--cx-account-border: #e0e4e9;
|
||||
--cx-account-surface: #f5f6f8;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 30px;
|
||||
width: fit-content;
|
||||
flex: 0 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--cx-account-border);
|
||||
border-radius: 7px;
|
||||
color: var(--cx-account-text);
|
||||
background: var(--cx-account-surface);
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cx-official-account-badge > svg { flex: 0 0 14px; }
|
||||
.cx-official-account-badge > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cx-official-account-badge--signed-out { --cx-account-text: #8d7552; --cx-account-border: #e8dfd1; --cx-account-surface: #f8f5ef; }
|
||||
.cx-official-account-badge--signed-in { --cx-account-text: #416aaa; --cx-account-border: #dce6f6; --cx-account-surface: #f3f7ff; }
|
||||
|
||||
:root[data-theme="dark"] .cx-official-account-badge { --cx-account-text: #b1b6bf; --cx-account-border: #484c55; --cx-account-surface: #373a42; }
|
||||
:root[data-theme="dark"] .cx-official-account-badge--signed-out { --cx-account-text: #cab591; --cx-account-border: #5a5142; --cx-account-surface: #3b3730; }
|
||||
:root[data-theme="dark"] .cx-official-account-badge--signed-in { --cx-account-text: #a4c4f5; --cx-account-border: #465c7d; --cx-account-surface: #304059; }
|
||||
|
||||
.cx-official-plan {
|
||||
--cx-plan-text: #596579;
|
||||
--cx-plan-line: #dce2eb;
|
||||
--cx-plan-background: #f3f5f8;
|
||||
min-width: 0;
|
||||
max-width: 125px;
|
||||
min-height: 25px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--cx-plan-line);
|
||||
border-radius: 6px;
|
||||
color: var(--cx-plan-text);
|
||||
background: var(--cx-plan-background);
|
||||
font-size: 10px;
|
||||
font-weight: 720;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cx-official-plan > svg { flex: 0 0 12px; }
|
||||
.cx-official-plan > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cx-official-plan--pro20 { --cx-plan-text: #815009; --cx-plan-line: #d6ad56; --cx-plan-background: linear-gradient(125deg, #fff9e5, #f6d891 55%, #fff1c9); box-shadow: 0 1px 7px rgb(175 125 31 / 16%), inset 0 1px 0 rgb(255 255 255 / 80%); }
|
||||
.cx-official-plan--pro5 { --cx-plan-text: #6d36b5; --cx-plan-line: #c5a5ef; --cx-plan-background: linear-gradient(125deg, #faf4ff, #e9d9fc); }
|
||||
.cx-official-plan--plus { --cx-plan-text: #275cc0; --cx-plan-line: #bacef3; --cx-plan-background: #eaf2ff; }
|
||||
.cx-official-plan--team { --cx-plan-text: #157168; --cx-plan-line: #a1d5c9; --cx-plan-background: #e4f5ef; }
|
||||
.cx-official-plan--enterprise { --cx-plan-text: #e9f1ff; --cx-plan-line: #718fbf; --cx-plan-background: linear-gradient(125deg, #25466e, #536991); box-shadow: inset 0 1px 0 rgb(255 255 255 / 20%); }
|
||||
.cx-official-plan--edu { --cx-plan-text: #3e6489; --cx-plan-line: #b4cde3; --cx-plan-background: #eaf3fa; }
|
||||
:root[data-theme="dark"] .cx-official-plan { --cx-plan-text: #bac4d3; --cx-plan-line: #4b5361; --cx-plan-background: #333b46; }
|
||||
:root[data-theme="dark"] .cx-official-plan--pro20 { --cx-plan-text: #ffe3a3; --cx-plan-line: #a9813d; --cx-plan-background: linear-gradient(125deg, #493721, #67502a 55%, #443522); box-shadow: 0 1px 8px rgb(213 159 57 / 14%), inset 0 1px 0 rgb(255 228 166 / 12%); }
|
||||
:root[data-theme="dark"] .cx-official-plan--pro5 { --cx-plan-text: #e1c4ff; --cx-plan-line: #8f64b9; --cx-plan-background: linear-gradient(125deg, #46305f, #593872); }
|
||||
:root[data-theme="dark"] .cx-official-plan--plus { --cx-plan-text: #b5d2ff; --cx-plan-line: #506f9f; --cx-plan-background: #2c4366; }
|
||||
:root[data-theme="dark"] .cx-official-plan--team { --cx-plan-text: #a4e4d6; --cx-plan-line: #497f76; --cx-plan-background: #284b46; }
|
||||
:root[data-theme="dark"] .cx-official-plan--enterprise { --cx-plan-text: #edf4ff; --cx-plan-line: #86a2cf; --cx-plan-background: linear-gradient(125deg, #2c405f, #4e6081); }
|
||||
:root[data-theme="dark"] .cx-official-plan--edu { --cx-plan-text: #b7d8ee; --cx-plan-line: #577c98; --cx-plan-background: #304a5d; }
|
||||
@@ -0,0 +1,111 @@
|
||||
.ui-modal.cx-official-quota-dialog {
|
||||
--ui-accent: #2563eb;
|
||||
--ui-accent-strong: #1d4ed8;
|
||||
--cx-quota-blue: #2563eb;
|
||||
--cx-quota-blue-soft: #f1f6ff;
|
||||
--cx-quota-blue-line: #dce7fb;
|
||||
--cx-quota-track: #dfe9fa;
|
||||
--cx-quota-warning: #a16722;
|
||||
--cx-quota-warning-soft: #fdf6ec;
|
||||
width: min(560px, calc(100vw - 40px));
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.cx-official-quota-dialog .ui-modal__description { overflow-wrap: anywhere; }
|
||||
.cx-official-quota-body { display: grid; gap: 18px; }
|
||||
.cx-quota-account { min-width: 0; display: flex; align-items: center; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--ui-border-subtle); }
|
||||
.cx-quota-account-icon { flex: 0 0 43px; width: 43px; height: 43px; display: grid; place-items: center; border: 1px solid var(--cx-quota-blue-line); border-radius: 12px; color: var(--cx-quota-blue); background: var(--cx-quota-blue-soft); }
|
||||
.cx-quota-account-copy { min-width: 0; max-width: calc(100% - 55px); display: grid; justify-items: start; gap: 7px; }
|
||||
.cx-quota-account-copy > div { min-width: 0; display: flex; align-items: center; gap: 5px; color: var(--cx-quota-blue); }
|
||||
.cx-quota-account-copy strong { color: var(--ui-text); font-size: 14px; line-height: 1.3; font-weight: 670; overflow-wrap: anywhere; }
|
||||
.cx-quota-account-email { max-width: 100%; font-size: 12px; }
|
||||
.cx-quota-account-copy svg { flex-shrink: 0; }
|
||||
.cx-quota-reset-credits { min-width: 0; margin-top: -4px; padding: 11px 13px; border: 1px solid var(--cx-quota-blue-line); border-radius: 9px; background: var(--cx-quota-blue-soft); }
|
||||
.cx-quota-reset-credits-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.cx-quota-reset-credits-row > span, .cx-quota-reset-credits-row > div { display: flex; align-items: center; gap: 8px; color: var(--cx-quota-blue); }
|
||||
.cx-quota-reset-credits-row > span { color: var(--ui-text); font-size: 12px; font-weight: 600; }
|
||||
.cx-quota-reset-credits-row > span > svg { color: var(--cx-quota-blue); }
|
||||
.cx-quota-reset-credits-row strong { display: flex; align-items: baseline; gap: 5px; font-size: 20px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.cx-quota-reset-credits-row strong small { color: var(--ui-text-muted); font-size: 11px; font-weight: 500; }
|
||||
.cx-quota-reset-credits-status { color: var(--ui-text-muted); font-size: 11px; }
|
||||
.cx-quota-reset-credits > p { display: flex; align-items: flex-start; gap: 6px; margin: 9px 0 0; color: var(--ui-danger); font-size: 10px; line-height: 1.6; }
|
||||
.cx-quota-reset-credits > p > svg { flex-shrink: 0; margin-top: 2px; }
|
||||
.cx-quota-reset-credits > p > span { min-width: 0; display: grid; gap: 2px; overflow-wrap: anywhere; }
|
||||
.cx-quota-reset-credits > p strong { font-weight: 600; }
|
||||
.cx-quota-reset-credits--error { border-color: color-mix(in srgb, var(--ui-danger) 25%, var(--cx-quota-blue-line)); }
|
||||
.cx-quota-limit { min-width: 0; }
|
||||
.cx-quota-limit-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 7px 10px; margin-bottom: 11px; }
|
||||
.cx-quota-limit-heading h3 { margin: 0; color: var(--ui-text); font-size: 12px; font-weight: 660; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.cx-quota-status { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px; border-radius: 5px; color: var(--cx-quota-blue); background: var(--cx-quota-blue-soft); font-size: 9px; line-height: 1.2; }
|
||||
.cx-quota-status--unavailable { color: var(--cx-quota-warning); background: var(--cx-quota-warning-soft); }
|
||||
.cx-quota-windows { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
|
||||
.cx-quota-window { min-width: 0; display: grid; grid-template-columns: minmax(94px, .8fr) minmax(100px, 1fr) minmax(150px, 1.2fr); align-items: center; gap: 14px; padding: 14px; border: 1px solid var(--cx-quota-blue-line); border-radius: 10px; background: var(--cx-quota-blue-soft); }
|
||||
.cx-quota-window-summary, .cx-quota-window-progress { min-width: 0; }
|
||||
.cx-quota-window-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cx-quota-window-heading strong { color: var(--ui-text); font-size: 12px; font-weight: 650; line-height: 1.5; }
|
||||
.cx-quota-window-progress > span { display: block; color: var(--ui-text-muted); font-size: 10px; line-height: 1.5; }
|
||||
.cx-quota-window-value { display: flex; align-items: baseline; gap: 3px; margin-top: 5px; color: var(--cx-quota-blue); font-variant-numeric: tabular-nums; }
|
||||
.cx-quota-window-value > span { font-size: 34px; line-height: 1.12; font-weight: 730; letter-spacing: -.045em; }
|
||||
.cx-quota-window-value > small { font-size: 16px; font-weight: 540; }
|
||||
.cx-quota-meter { width: 100%; height: 6px; margin-top: 8px; overflow: hidden; border-radius: 5px; background: var(--cx-quota-track); }
|
||||
.cx-quota-meter > span { display: block; height: 100%; border-radius: 5px; background: var(--cx-quota-blue); }
|
||||
.cx-quota-meter--unknown { background: repeating-linear-gradient(120deg, var(--cx-quota-track) 0 5px, transparent 5px 9px); }
|
||||
.cx-quota-window-used { margin-top: 6px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||||
.cx-quota-reset { min-width: 0; display: flex; align-items: flex-start; gap: 6px; padding-left: 12px; border-left: 1px solid var(--cx-quota-blue-line); color: var(--ui-text-muted); }
|
||||
.cx-quota-reset > svg { flex-shrink: 0; margin-top: 2px; }
|
||||
.cx-quota-reset > div { min-width: 0; display: grid; gap: 3px; }
|
||||
.cx-quota-reset span { font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.cx-quota-reset time { color: var(--ui-text-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.cx-quota-reset--due { color: var(--cx-quota-warning); }
|
||||
.cx-quota-window--low .cx-quota-window-value { color: var(--cx-quota-warning); }
|
||||
.cx-quota-window--low .cx-quota-meter > span { background: var(--cx-quota-warning); }
|
||||
.cx-quota-other { border: 1px solid var(--ui-border-subtle); border-radius: 10px; }
|
||||
.cx-quota-other > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; color: var(--ui-text-muted); font-size: 11px; font-weight: 590; cursor: pointer; list-style: none; }
|
||||
.cx-quota-other > summary::-webkit-details-marker { display: none; }
|
||||
.cx-quota-other > summary > span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.cx-quota-other > summary small { min-width: 19px; padding: 2px 5px; border-radius: 5px; background: var(--ui-surface-muted); text-align: center; font-size: 9px; font-variant-numeric: tabular-nums; }
|
||||
.cx-quota-other > summary > svg { transition: transform 140ms ease; }
|
||||
.cx-quota-other[open] > summary > svg { transform: rotate(180deg); }
|
||||
.cx-quota-other > summary:focus-visible { outline: 2px solid var(--cx-quota-blue); outline-offset: 3px; border-radius: 8px; }
|
||||
.cx-quota-other > div { display: grid; gap: 18px; padding: 0 13px 13px; }
|
||||
.cx-quota-limit--compact { border-top: 1px solid var(--ui-border-subtle); padding-top: 13px; }
|
||||
.cx-quota-window--compact { padding: 12px; background: var(--ui-surface-elevated); border-color: var(--ui-border-subtle); border-radius: 8px; }
|
||||
.cx-quota-window--compact .cx-quota-window-value { margin-top: 5px; }
|
||||
.cx-quota-window--compact .cx-quota-window-value > span { font-size: 29px; }
|
||||
.cx-quota-window--compact .cx-quota-window-value > small { font-size: 14px; }
|
||||
.cx-quota-window--compact .cx-quota-meter { height: 5px; }
|
||||
.cx-quota-window--compact .cx-quota-reset { border-left-color: var(--ui-border-subtle); }
|
||||
.cx-quota-footnote { color: var(--ui-text-subtle); font-size: 10px; line-height: 1.6; }
|
||||
.cx-quota-footnote > span { display: flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; }
|
||||
.cx-quota-footnote > span > svg { color: var(--cx-quota-blue); }
|
||||
.cx-quota-footnote p { margin: 4px 0 0; font-size: 10px; }
|
||||
.cx-quota-loading, .cx-quota-empty { min-height: 195px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 20px 15px; text-align: center; }
|
||||
.cx-quota-loading > svg, .cx-quota-empty > svg { color: var(--cx-quota-blue); }
|
||||
.cx-quota-loading > strong, .cx-quota-empty > strong { margin-top: 1px; color: var(--ui-text); font-size: 12px; line-height: 1.5; font-weight: 610; }
|
||||
.cx-quota-loading > p, .cx-quota-empty > p { max-width: 310px; margin: -5px 0 0; color: var(--ui-text-muted); font-size: 11px; line-height: 1.7; }
|
||||
.cx-quota-loading-bars { display: grid; gap: 7px; width: 170px; margin-top: 5px; }
|
||||
.cx-quota-loading-bars > span { height: 6px; border-radius: 5px; background: var(--cx-quota-track); }
|
||||
.cx-quota-loading-bars > span:last-child { width: 70%; }
|
||||
.cx-quota-no-window { padding: 16px; border: 1px dashed var(--ui-border); border-radius: 9px; color: var(--ui-text-muted); }
|
||||
.cx-quota-no-window strong { font-size: 11px; font-weight: 600; }
|
||||
.cx-quota-no-window p { margin: 6px 0 0; font-size: 10px; line-height: 1.6; }
|
||||
.cx-quota-error { display: flex; align-items: flex-start; gap: 9px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--ui-danger) 25%, transparent); border-radius: 9px; color: var(--ui-danger); background: var(--ui-danger-soft); }
|
||||
.cx-quota-error > svg { flex-shrink: 0; margin-top: 2px; }
|
||||
.cx-quota-error > div { min-width: 0; }
|
||||
.cx-quota-error strong { font-size: 11px; line-height: 1.5; font-weight: 640; }
|
||||
.cx-quota-error p { margin: 5px 0 0; font-size: 10px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-quota-spin { animation: cx-quota-spin 1s linear infinite; }
|
||||
|
||||
:root[data-theme="dark"] .cx-official-quota-dialog { --ui-accent: #3375e5; --ui-accent-strong: #4285f4; --cx-quota-blue: #76a8ff; --cx-quota-blue-soft: #2c3c56; --cx-quota-blue-line: #415675; --cx-quota-track: #41536e; --cx-quota-warning: #dfb16f; --cx-quota-warning-soft: #463c2e; }
|
||||
|
||||
@keyframes cx-quota-spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 540px) {
|
||||
.cx-quota-window { grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); gap: 12px 18px; }
|
||||
.cx-quota-reset { grid-column: 1 / -1; padding: 10px 0 0; border-left: 0; border-top: 1px solid var(--cx-quota-blue-line); }
|
||||
.cx-quota-reset > div { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 10px; flex: 1; }
|
||||
.cx-quota-window--compact .cx-quota-reset { border-top-color: var(--ui-border-subtle); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cx-quota-spin { animation: none; }
|
||||
.cx-quota-other > summary > svg { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
.cx-failover {
|
||||
--rf-text: #202a3a; --rf-muted: #566378; --rf-border: #ced7e4; --rf-surface: #ffffff; --rf-inset: #f4f7fb; --rf-input: #ffffff;
|
||||
--rf-blue: #215adb; --rf-blue-bg: #edf3ff; --rf-blue-border: #a4bff3;
|
||||
--rf-green: #17643f; --rf-green-bg: #e9f8ef; --rf-green-border: #9cceb0;
|
||||
--rf-amber: #815109; --rf-amber-bg: #fff5df; --rf-amber-border: #e3c27b;
|
||||
--rf-red: #b32d37; --rf-red-bg: #fff0f0; --rf-red-border: #e2a6aa;
|
||||
display: grid; gap: 18px; max-width: 1120px; color: var(--rf-text); font-size: 14px;
|
||||
}
|
||||
:root[data-theme="dark"] .cx-failover {
|
||||
--rf-text: #f1f5fb; --rf-muted: #c0cad9; --rf-border: #525e70; --rf-surface: #303744; --rf-inset: #272e39; --rf-input: #202731;
|
||||
--rf-blue: #a3c4ff; --rf-blue-bg: #263e65; --rf-blue-border: #6089ca;
|
||||
--rf-green: #a2e6b9; --rf-green-bg: #233e31; --rf-green-border: #4e8162;
|
||||
--rf-amber: #f5d191; --rf-amber-bg: #453927; --rf-amber-border: #957647;
|
||||
--rf-red: #ffb3bc; --rf-red-bg: #4b2c35; --rf-red-border: #9b5d6b;
|
||||
}
|
||||
.cx-failover h3, .cx-failover h4, .cx-failover h5, .cx-failover p { margin: 0; }
|
||||
.cx-failover h3 { color: var(--rf-text); font-size: 20px; font-weight: 750; }
|
||||
.cx-failover h4 { color: var(--rf-text); font-size: 15px; font-weight: 720; }
|
||||
.cx-failover-heading { display: flex; justify-content: space-between; gap: 20px; align-items: center; }
|
||||
.cx-failover-heading p { margin-top: 6px; color: var(--rf-muted); line-height: 1.6; font-size: 13px; }
|
||||
.cx-failover-card { overflow: hidden; border: 1px solid var(--rf-border); border-radius: 13px; background: var(--rf-surface); box-shadow: 0 3px 12px #00000006; }
|
||||
.cx-failover-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px 20px; border-bottom: 1px solid var(--rf-border); background: var(--rf-inset); }
|
||||
.cx-failover-card-heading h4 { display: flex; align-items: center; gap: 9px; }
|
||||
.cx-failover-card-heading h4 svg { color: var(--rf-blue); }
|
||||
.cx-failover-section-note { color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-toggle-row { display: flex; align-items: center; gap: 13px; padding: 19px 20px; }
|
||||
.cx-failover-toggle-copy { min-width: 0; flex: 1; }
|
||||
.cx-failover-toggle-copy label { display: inline-block; color: var(--rf-text); font-size: 14px; font-weight: 710; cursor: pointer; }
|
||||
.cx-failover-toggle-copy p { margin-top: 5px; line-height: 1.6; color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-symbol { display: grid; place-items: center; flex: 0 0 40px; width: 40px; height: 40px; color: var(--rf-blue); background: var(--rf-blue-bg); border: 1px solid var(--rf-blue-border); border-radius: 11px; }
|
||||
.cx-failover-switch { position: relative; flex: 0 0 44px; width: 44px; height: 26px; border: 1px solid #738198; border-radius: 20px; background: #78859a; cursor: pointer; transition: background-color 130ms ease; }
|
||||
.cx-failover-switch span { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #0003; transition: transform 130ms ease; }
|
||||
.cx-failover-switch[aria-checked="true"] { background: #2866ed; border-color: #2866ed; }
|
||||
.cx-failover-switch[aria-checked="true"] span { transform: translateX(18px); }
|
||||
.cx-failover-switch:disabled { cursor: not-allowed; border-style: dashed; }
|
||||
.cx-failover-switch:disabled:not([aria-checked="true"]) { background: #697383; }
|
||||
.cx-failover-switch:focus-visible, .cx-failover-candidate:focus-visible, .cx-failover input:focus-visible { outline: 2px solid var(--rf-blue); outline-offset: 3px; }
|
||||
.cx-failover-address-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; padding: 0 20px 18px 73px; border-bottom: 1px solid var(--rf-border); }
|
||||
.cx-failover-address-grid label, .cx-failover-fields label { display: grid; gap: 8px; align-content: start; color: var(--rf-text); font-size: 13px; font-weight: 650; }
|
||||
.cx-failover input { box-sizing: border-box; width: 100%; min-width: 0; height: 39px; padding: 8px 11px; color: var(--rf-text); background: var(--rf-input); border: 1px solid var(--rf-border); border-radius: 7px; font: inherit; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
.cx-failover input:disabled { color: var(--rf-muted); background: var(--rf-inset); cursor: not-allowed; }
|
||||
.cx-failover input[aria-invalid="true"] { border-color: var(--rf-red); box-shadow: 0 0 0 1px var(--rf-red); }
|
||||
.cx-failover label small { font-size: 12px; font-weight: 400; color: var(--rf-muted); line-height: 1.55; }
|
||||
.cx-failover .cx-failover-field-error { color: var(--rf-red); }
|
||||
.cx-failover p.cx-failover-inline-hint { padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.6; font-size: 12px; }
|
||||
.cx-failover-primary { display: flex; gap: 12px; align-items: center; margin: 0 20px 18px; padding: 14px; border-radius: 9px; border: 1px solid var(--rf-border); background: var(--rf-inset); }
|
||||
.cx-failover-provider-copy { flex: 1; min-width: 0; display: grid; gap: 5px; }
|
||||
.cx-failover-provider-copy strong, .cx-failover-candidate strong { color: var(--rf-text); font-size: 14px; font-weight: 740; overflow-wrap: anywhere; }
|
||||
.cx-failover-provider-copy small, .cx-failover-candidate small { color: var(--rf-muted); font-size: 12px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cx-failover-label { color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-state { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; flex: 0 0 auto; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); border: 1px solid var(--rf-border); font-size: 12px; font-weight: 650; }
|
||||
.cx-failover-state i, .cx-failover-health i { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: currentColor; }
|
||||
.cx-failover-state--running { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
|
||||
.cx-failover-service-address { display: flex; gap: 12px; align-items: center; padding: 0 20px 18px; color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-service-address code { padding: 6px 10px; border-radius: 6px; background: var(--rf-inset); color: var(--rf-text); overflow-wrap: anywhere; }
|
||||
.cx-failover-queue-heading { display: flex; align-items: flex-start; gap: 16px; justify-content: space-between; padding: 3px 20px 14px; }
|
||||
.cx-failover-queue-heading p { margin-top: 7px; font-size: 12px; color: var(--rf-muted); line-height: 1.6; }
|
||||
.cx-failover-queue-heading > span { color: var(--rf-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.cx-failover-empty { display: grid; gap: 8px; margin: 0 20px; padding: 20px; border-radius: 9px; border: 1px dashed var(--rf-border); background: var(--rf-inset); }
|
||||
.cx-failover-empty strong { font-size: 14px; font-weight: 650; }
|
||||
.cx-failover-empty span { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
|
||||
.cx-failover-queue { display: grid; gap: 9px; list-style: none; margin: 0 20px; padding: 0; }
|
||||
.cx-failover-queue > li { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid var(--rf-border); border-radius: 9px; background: var(--rf-surface); }
|
||||
.cx-failover-queue > li.cx-failover-provider--first { border-color: var(--rf-blue-border); background: var(--rf-blue-bg); }
|
||||
.cx-failover-order { display: grid; place-items: center; flex: 0 0 34px; width: 34px; height: 30px; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 750; }
|
||||
.cx-failover-provider--first .cx-failover-order { background: #2866ed; color: #fff; }
|
||||
.cx-failover-queue > li.cx-failover-provider--invalid { border-color: var(--rf-red-border); }
|
||||
.cx-failover-provider--invalid .cx-failover-provider-copy small { color: var(--rf-red); white-space: normal; }
|
||||
.cx-failover-row-actions { display: flex; gap: 4px; flex: 0 0 auto; }
|
||||
.cx-failover-health { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; padding: 5px 8px; border-radius: 7px; border: 1px solid var(--rf-border); color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 620; }
|
||||
.cx-failover-health--closed { background: var(--rf-green-bg); color: var(--rf-green); border-color: var(--rf-green-border); }
|
||||
.cx-failover-health--half_open { background: var(--rf-amber-bg); color: var(--rf-amber); border-color: var(--rf-amber-border); }
|
||||
.cx-failover-health--open { background: var(--rf-red-bg); color: var(--rf-red); border-color: var(--rf-red-border); }
|
||||
.cx-failover-candidates { padding: 18px 20px 20px; }
|
||||
.cx-failover-candidates > p { padding-top: 10px; font-size: 12px; line-height: 1.6; color: var(--rf-muted); }
|
||||
.cx-failover-candidate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 11px; }
|
||||
.cx-failover-candidate { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-width: 0; padding: 12px; border: 1px solid var(--rf-border); border-radius: 8px; text-align: left; background: var(--rf-inset); color: var(--rf-text); cursor: pointer; }
|
||||
.cx-failover-candidate > div { min-width: 0; display: grid; gap: 5px; }
|
||||
.cx-failover-candidate > svg { flex: 0 0 auto; color: var(--rf-blue); }
|
||||
.cx-failover-candidate:hover:not(:disabled) { border-color: var(--rf-blue); background: var(--rf-blue-bg); }
|
||||
.cx-failover-candidate:disabled { cursor: not-allowed; border-style: dashed; }
|
||||
.cx-failover-candidate:disabled svg { color: var(--rf-muted); }
|
||||
.cx-failover-field-group { padding: 18px 20px 20px; }
|
||||
.cx-failover-field-group + .cx-failover-field-group { border-top: 1px solid var(--rf-border); }
|
||||
.cx-failover-field-group h5 { color: var(--rf-blue); margin-bottom: 12px; font-size: 13px; font-weight: 700; }
|
||||
.cx-failover-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.cx-failover-field-input { position: relative; }
|
||||
.cx-failover-field-input input { padding-right: 68px; }
|
||||
.cx-failover-field-input > span { position: absolute; right: 24px; top: 11px; color: var(--rf-muted); font-size: 11px; font-weight: 400; pointer-events: none; }
|
||||
.cx-failover-runtime dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin: 0; padding: 20px; }
|
||||
.cx-failover-runtime dt { color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-runtime dd { margin: 8px 0 0; font-size: 23px; font-weight: 740; font-variant-numeric: tabular-nums; }
|
||||
.cx-failover-runtime dd span { color: var(--rf-muted); font-size: 18px; font-weight: 500; }
|
||||
.cx-failover-runtime-current { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--rf-border); color: var(--rf-muted); font-size: 12px; }
|
||||
.cx-failover-runtime-current strong { color: var(--rf-text); font-size: 14px; }
|
||||
.cx-failover-runtime-current time { margin-left: auto; }
|
||||
.cx-failover-runtime p.cx-failover-runtime-note { display: flex; gap: 7px; margin: 0; padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.65; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.cx-failover-runtime-note svg { flex: 0 0 auto; margin-top: 2px; }
|
||||
.cx-failover-message { display: flex; align-items: center; gap: 9px; padding: 13px 15px; border: 1px solid var(--rf-border); border-radius: 9px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.cx-failover-message svg { flex: 0 0 auto; }
|
||||
.cx-failover-message > div { flex: 1; min-width: 0; display: grid; gap: 3px; }
|
||||
.cx-failover-message--warning { color: var(--rf-amber); background: var(--rf-amber-bg); border-color: var(--rf-amber-border); }
|
||||
.cx-failover-message--success { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
|
||||
.cx-failover-bottom-note { color: var(--rf-muted); font-size: 12px; line-height: 1.7; }
|
||||
.cx-failover-save { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 18px; border: 1px solid var(--rf-border); border-radius: 10px; background: var(--rf-surface); box-shadow: 0 -4px 18px #0000000a; }
|
||||
.cx-failover-save > div:first-child { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
|
||||
.cx-failover-save > div:first-child.cx-failover-field-error { color: var(--rf-red); }
|
||||
.cx-failover-save > div:last-child { display: flex; flex: 0 0 auto; gap: 8px; }
|
||||
.cx-failover-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 180px; color: var(--rf-muted); font-size: 14px; }
|
||||
.cx-failover-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
|
||||
@media (max-width: 850px) {
|
||||
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cx-failover-queue > li { flex-wrap: wrap; }
|
||||
.cx-failover-queue .cx-failover-provider-copy { flex-basis: calc(100% - 52px); }
|
||||
.cx-failover-row-actions { margin-left: auto; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.cx-failover-address-grid { padding-left: 20px; grid-template-columns: 1fr; }
|
||||
.cx-failover-candidate-list, .cx-failover-fields, .cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: 1fr; }
|
||||
.cx-failover-primary { flex-wrap: wrap; }
|
||||
.cx-failover-service-address { align-items: flex-start; flex-direction: column; }
|
||||
.cx-failover-runtime dl { grid-template-columns: 1fr 1fr; }
|
||||
.cx-failover-save { align-items: stretch; flex-direction: column; }
|
||||
.cx-failover-save > div:last-child { justify-content: flex-end; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .cx-failover-switch, .cx-failover-switch span { transition: none; } }
|
||||
@@ -0,0 +1,189 @@
|
||||
.cx-preset-picker {
|
||||
--cx-preset-surface: #f6f7f9;
|
||||
--cx-preset-hover: #eef3fb;
|
||||
--cx-preset-border: #e8ebf0;
|
||||
--cx-preset-text: var(--cx-provider-text, #20242a);
|
||||
--cx-preset-muted: var(--cx-provider-muted, #747b85);
|
||||
--cx-preset-blue: #2563eb;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-bottom: 18px;
|
||||
container: provider-presets / inline-size;
|
||||
}
|
||||
|
||||
.cx-preset-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cx-preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.cx-preset-option {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-preset-radio {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cx-preset-card {
|
||||
min-height: 48px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--cx-preset-border);
|
||||
border-radius: 10px;
|
||||
color: var(--cx-preset-text);
|
||||
background: var(--cx-preset-surface);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.cx-preset-radio:not(:disabled) + .cx-preset-card:hover {
|
||||
border-color: #b8cdf4;
|
||||
background: var(--cx-preset-hover);
|
||||
}
|
||||
|
||||
.cx-preset-radio:focus-visible + .cx-preset-card {
|
||||
outline: 2px solid var(--cx-preset-blue);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cx-preset-radio:checked + .cx-preset-card,
|
||||
.cx-preset-radio:checked:not(:disabled) + .cx-preset-card:hover {
|
||||
color: #fff;
|
||||
border-color: var(--cx-preset-blue);
|
||||
background: var(--cx-preset-blue);
|
||||
box-shadow: 0 3px 8px rgb(37 99 235 / 14%);
|
||||
}
|
||||
|
||||
.cx-preset-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cx-preset-copy strong {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cx-preset-mark {
|
||||
display: flex;
|
||||
flex: 0 0 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--cx-preset-brand, #6b7280);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--cx-preset-brand, #6b7280) 9%, transparent);
|
||||
font-size: 20px;
|
||||
font-weight: 780;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.cx-preset-mark--openai { --cx-preset-brand: #10967a; }
|
||||
.cx-preset-mark--deepseek { --cx-preset-brand: #4d6bfe; }
|
||||
.cx-preset-mark--minimax { --cx-preset-brand: #e95657; }
|
||||
.cx-preset-mark--mimo { --cx-preset-brand: #ed7014; font-size: 18px; }
|
||||
.cx-preset-mark--qwen { --cx-preset-brand: #7c4ddd; }
|
||||
.cx-preset-mark--kimi { --cx-preset-brand: #6055e8; }
|
||||
.cx-preset-mark--glm { --cx-preset-brand: #3879e1; }
|
||||
.cx-preset-mark--doubao { --cx-preset-brand: #258b83; font-size: 17px; }
|
||||
|
||||
.cx-preset-brand-logo {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: currentColor;
|
||||
-webkit-mask: url("/openai.svg") center / contain no-repeat;
|
||||
mask: url("/openai.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
.cx-preset-radio:checked + .cx-preset-card .cx-preset-mark {
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 14%);
|
||||
}
|
||||
|
||||
.cx-preset-selected {
|
||||
flex: 0 0 17px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 50%;
|
||||
visibility: hidden;
|
||||
color: var(--cx-preset-blue);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cx-preset-radio:checked + .cx-preset-card .cx-preset-selected { visibility: visible; }
|
||||
.cx-preset-radio:disabled + .cx-preset-card { cursor: not-allowed; opacity: 0.55; box-shadow: none; }
|
||||
.cx-preset-details { margin-top: 14px; }
|
||||
|
||||
.cx-preset-variant-panel {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--cx-preset-border);
|
||||
border-radius: 10px;
|
||||
background: var(--cx-preset-surface);
|
||||
}
|
||||
.cx-preset-variants { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.cx-preset-variants > span { margin-right: 4px; color: var(--cx-preset-muted); font-size: 12px; }
|
||||
.cx-preset-variants button {
|
||||
border: 1px solid var(--cx-preset-border);
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
padding: 6px 12px;
|
||||
color: var(--cx-preset-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cx-preset-variants button[aria-pressed="true"] { color: white; background: var(--cx-preset-blue); border-color: var(--cx-preset-blue); }
|
||||
.cx-preset-variants button:focus-visible { outline: 2px solid var(--cx-preset-blue); outline-offset: 2px; }
|
||||
.cx-preset-variants button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.cx-preset-variant-panel p { margin: 8px 0 0; font-size: 12px; line-height: 1.6; color: var(--cx-preset-muted); }
|
||||
.cx-preset-variant-panel p:first-child { margin-top: 0; }
|
||||
|
||||
:root[data-theme="dark"] .cx-preset-picker {
|
||||
--cx-preset-surface: var(--cx-dark-surface-raised, #30333a);
|
||||
--cx-preset-hover: #303d55;
|
||||
--cx-preset-border: var(--cx-dark-border, #454a54);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-preset-mark--openai { --cx-preset-brand: #4ccbb0; }
|
||||
:root[data-theme="dark"] .cx-preset-mark--deepseek { --cx-preset-brand: #91a3ff; }
|
||||
:root[data-theme="dark"] .cx-preset-mark--minimax { --cx-preset-brand: #fa9393; }
|
||||
:root[data-theme="dark"] .cx-preset-mark--mimo { --cx-preset-brand: #f9ab66; }
|
||||
:root[data-theme="dark"] .cx-preset-mark--qwen { --cx-preset-brand: #b49aff; }
|
||||
:root[data-theme="dark"] .cx-preset-mark--kimi { --cx-preset-brand: #aaa1ff; }
|
||||
:root[data-theme="dark"] .cx-preset-mark--glm { --cx-preset-brand: #87b4fa; }
|
||||
:root[data-theme="dark"] .cx-preset-mark--doubao { --cx-preset-brand: #75c8b8; }
|
||||
|
||||
@container provider-presets (max-width: 670px) {
|
||||
.cx-preset-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@container provider-presets (max-width: 490px) {
|
||||
.cx-preset-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user