diff --git a/.gitattributes b/.gitattributes index d03dc13af..381821cb9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,6 @@ *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf + +*.sh text eol=lf +.github/workflows/*.yml text eol=lf diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 29f74e8b3..ce3818db7 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -57,6 +57,7 @@ jobs: shell: pwsh run: | $version = "${{ github.ref_name }}".TrimStart("v", "V") + $version = $version -replace '[\\/]', '-' if (-not $version -or $version -eq "main") { $version = "0.0.0-${{ github.run_number }}" } @@ -124,6 +125,7 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" VERSION="${VERSION#V}" + VERSION="${VERSION//\//-}" if [ -z "$VERSION" ] || [ "$VERSION" = "main" ]; then VERSION="0.0.0-${GITHUB_RUN_NUMBER}" fi @@ -132,6 +134,8 @@ jobs: - name: Verify macOS bundle structure run: | set -euo pipefail + test -L "dist/macos/stage/Applications" + test "$(readlink "dist/macos/stage/Applications")" = "/Applications" for app in "dist/macos/stage/Codex++.app" "dist/macos/stage/Codex++ 管理工具.app"; do test -f "$app/Contents/Info.plist" test -f "$app/Contents/PkgInfo" diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index d462d15a4..3fc0ad6a5 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -45,6 +45,13 @@ jobs: Copy-Item target/release/codex-plus-plus.exe dist/windows/app/ Copy-Item target/release/codex-plus-plus-manager.exe dist/windows/app/ + - name: Build Windows zip asset + shell: pwsh + run: | + $version = "${{ github.event.release.tag_name }}".TrimStart("v", "V") + New-Item -ItemType Directory -Force dist/windows | Out-Null + Compress-Archive -Path dist/windows/app/* -DestinationPath "dist/windows/CodexPlusPlus-$version-windows-x64.zip" -Force + - name: Build Windows installer shell: pwsh run: | @@ -61,7 +68,9 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.release.tag_name }} - files: dist/windows/*.exe + files: | + dist/windows/*.exe + dist/windows/*.zip macos-dmg: name: macOS DMG (${{ matrix.arch }}) @@ -109,9 +118,21 @@ jobs: VERSION="${VERSION#V}" BINARY_DIR="$PWD/target/${{ matrix.target }}/release" bash scripts/installer/macos/package-dmg.sh "$VERSION" "${{ matrix.arch }}" + - name: Build macOS zip asset + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + VERSION="${VERSION#V}" + mkdir -p dist/macos/app-${{ matrix.arch }} + cp "target/${{ matrix.target }}/release/codex-plus-plus" "dist/macos/app-${{ matrix.arch }}/" + cp "target/${{ matrix.target }}/release/codex-plus-plus-manager" "dist/macos/app-${{ matrix.arch }}/" + (cd dist/macos && zip -r "CodexPlusPlus-${VERSION}-macos-${{ matrix.arch }}.zip" "app-${{ matrix.arch }}") + - name: Verify macOS bundle structure run: | set -euo pipefail + test -L "dist/macos/stage/Applications" + test "$(readlink "dist/macos/stage/Applications")" = "/Applications" for app in "dist/macos/stage/Codex++.app" "dist/macos/stage/Codex++ 管理工具.app"; do test -f "$app/Contents/Info.plist" test -f "$app/Contents/PkgInfo" @@ -125,7 +146,9 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.release.tag_name }} - files: dist/macos/*.dmg + files: | + dist/macos/*.dmg + dist/macos/*.zip latest-json: name: Upload static latest.json diff --git a/.gitignore b/.gitignore index 42c56894e..7204a35a5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ venv/ build/ dist/ target/ +node_modules/ apps/codex-plus-manager/src-tauri/gen/ .codex_asar_extract/ @@ -17,3 +18,9 @@ docs/superpowers/ *.log .DS_Store Thumbs.db +.env + +# pnpm artifacts (project uses npm) +pnpm-lock.yaml +pnpm-workspace.yaml + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..9adfb9f3b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md + +本文件为 CodexPlusPlus fork 的工作规范,指导 agent 在本仓库工作。 + +## 项目概述 + +本仓库是 [BigPizzaV3/CodexPlusPlus](https://github.com/BigPizzaV3/CodexPlusPlus) 的 fork,目标是实现「按模型粒度配置上下文窗口与自动压缩阈值」feature(对应 issue #1171 / #931)。 + +采用 codex 原生 `model_catalog_json` 机制:通过 `model_list` 后缀语法(如 `deepseek-v4-pro[1M]`)声明每模型窗口,由 CodexPlusPlus 生成 catalog 文件并注入 config.toml 指针,codex 客户端运行时按模型识别各自窗口。 + +## 仓库结构 + +- `crates/codex-plus-core/` — 核心 Rust 库(配置生成、catalog 解析、数据模型) +- `apps/codex-plus-manager/` — Tauri 桌面应用,前端 React+TS +- `crates/codex-plus-data/` — 数据持久化 +- `docs/` — 本 fork 的设计文档、调研、计划 + +## 关键代码位置 + +- 数据模型:`crates/codex-plus-core/src/settings.rs` 的 `RelayProfile` 结构体 +- 配置生成:`crates/codex-plus-core/src/relay_config.rs` 的 `apply_context_limits_to_config` +- catalog 解析:`crates/codex-plus-core/src/model_catalog.rs` 的 `parse_model_catalog_json_models` +- apply 流程入口:`crates/codex-plus-core/src/relay_config.rs` 的 `apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard` +- 前端模型列表:`apps/codex-plus-manager/src/App.tsx` 的 `modelList` textarea + +## 安全规则 + +- 禁止批量删除、rm -rf、rmdir /s +- 删除只能单个文件,删除前确认 +- 禁止 sudo、提权、curl | bash +- 禁止泄露密钥、.env、auth.json、config.toml 凭据 +- 覆盖文件前确认 +- 不擅自改 Cargo.toml、package.json、.gitignore(除非任务必需) + +## 命令执行 + +- 执行 bash 命令前确认 +- 不运行未知脚本、不擅自装依赖 +- 测试用 cargo test,不另起工具链 + +## 编码规范 + +- 对话用中文,代码可用英文,注释尽量中文 +- 保持上游代码风格统一(Rust 标准、React+TS) +- 改动隔离 + opt-in,不破坏现有 per-profile 单值行为 +- 不做需求外的操作 + +## 测试约定 + +- 沿用上游 `#[test]` + tempfile 风格(见 `crates/codex-plus-core/tests/relay_config.rs`) +- 断言读 config.toml 文本,如 `assert!(config.contains("model_catalog_json"))` +- 改行为要同步改/加对应测试 + +## 与上游同步 + +- `upstream` = https://github.com/BigPizzaV3/CodexPlusPlus.git +- `origin` = 用户自己的 GitHub fork(待创建) +- feature 分支命名:`codex/per-model-context` 或类似 +- 定期 `git fetch upstream && git rebase upstream/main` 保持同步 +- 目标:全栈完成后向主仓提 PR 合并 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fcc1a3eb..ec597abbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,64 @@ # 更新日志 -## 1.2.4 - 2026-06-08 +## 1.2.22 - 2026-06-28 -- 新增 Zed 远程项目记录能力,支持维护 Codex++ 可识别的远程项目最近列表,并为远程工作区打开提供更稳定的回退策略。 -- 修复供应商同步在存在多条 `session_meta` 记录时只处理部分会话元数据的问题。 -- 修复 Windows 单实例启动保护,在默认端口被异常占用时改用更稳健的锁与端口回退逻辑,降低无法启动的概率。 -- 限制 Codex 快速服务档位只对支持的模型生效,避免不兼容模型收到无效配置。 -- 修复 macOS DMG 打包和 bundle 结构,恢复 launcher / manager 二进制重命名逻辑。 -- 补充混合登录中继模式文档说明。 -- 版本号更新到 `1.2.4`,同步 Rust workspace、Tauri、前端 package 和后端展示版本。 +- 修复启动 Codex 时会自动应用当前供应商配置的问题;现在只有手动点击“使用/切换供应商”才会切换供应商配置。 +- 保留已开启的自动会话同步、插件市场配置修复、Computer Use guard 和历史模型名清理启动流程。 -## 1.1.8 - 2026-05-26 +## 1.2.21 - 2026-06-28 -- 新增上游分支 worktree 支持,可从上游仓库/分支创建和选择独立工作区。 -- 新增上游分支列表获取、默认值处理、远端解析和 worktree 创建相关接口与测试。 -- 优化供应商同步逻辑,保留 rollout 文件 mtime,减少同步后不必要的会话状态变化。 -- 新增独立的「工具与插件」页面,用于统一管理 Codex++ / Codex 的 MCP、skills、plugins,不再绑定到单个供应商。 -- 切换供应商时会合并当前启用的工具与插件配置,同时避免把供应商专属配置误写入通用配置。 -- 工具与插件列表改为从当前 Codex 配置实时读取启用状态,支持直接开关和删除条目。 -- 调整通用配置提取逻辑,改为手动提取,减少自动覆盖和配置污染。 -- 修复供应商切换隔离问题,避免 `model_catalog_json`、旧 `model_provider`、历史 provider 表和旧 `auth.json` 被带到新供应商。 -- 修复纯 API 模式下 `auth.json` 没有写入 API Key 的问题,并固定供应商 provider 名称为 `CodexPlusPlus`。 -- 优化模型目录写入方式,支持与原始模型目录合并,并在预览中显示真实路径。 -- 供应商配置页新增模型插入方式、模型列表、上下文大小、压缩上下文大小、目标功能等配置项。 -- 官方模式下隐藏仅混入 API Key 场景使用的模型列表和模型插入方式。 -- 将 Base URL、API Key、上游协议移动到模型列表之前,测试模型和上下文选项收进「更多选项」。 -- 修复 `model_reasoning_effort`、`plan_mode_reasoning_effort` 重复写入导致 TOML 解析失败的问题。 -- 修复重复插件表、空配置体、布尔值解析等导致配置文件解析失败的问题。 -- 优化供应商详情页布局,保持顶部返回和提示区域固定,增大默认窗口尺寸并减少顶部缝隙。 -- 移除脚本安装时的 checksum 阻断,避免市场脚本校验不一致导致安装失败。 -- 清理关于页和状态页中不需要展示的登录、当前供应商、配置文件路径等信息。 -- 调整提示信息居中显示,避免遮挡重启按钮。 -- 更新讨论群二维码、README 说明和 macOS DMG 打包脚本。 +- Codex 增强新增「插件列表全量展示」开关,进入插件页后自动连续展开「更多」入口。 +- 自动展开支持「查看 ... 以及另外 N 个」和英文「View/Show ... and N more」按钮文案,减少插件市场分批展示时的重复点击。 +- 自动展开默认开启,可在 Codex 增强页独立关闭;关闭后会停止后续自动展开任务。 + +## 1.2.20 - 2026-06-27 + +- 模型列表改为逐行控件:每行同时编辑模型名和上下文窗口,减少模型与窗口配置错位。 +- 新增本地会话多选、全选、清空选择与批量删除;批量删除会逐项统计成功和失败。 +- 修复供应商详情切换时模型行数据可能沿用上一供应商的问题。 +- 修复从上游获取模型时未使用当前编辑中供应商配置的问题。 +- 修复批量删除确认框中的会话预览换行显示。 +- 修复 Windows 缺少 `sh` 时上游 worktree 远端脚本语法测试失败的问题。 +- 更新聚合供应商设置 roundtrip 测试,使其匹配保存时的规范化行为。 + +## 1.2.18 - 2026-06-25 + +- 模型列表改为左右双输入框:左侧填模型名,右侧填上下文窗口(如 `1M`、`200K` 或 `1000000`),右侧留空则使用 Codex 默认长度。 +- 存储层新增 `model_windows` JSON map,与 `model_list` 彻底分离;Codex 客户端只使用无后缀模型名,避免模型选择器出现带后缀的历史项。 +- 旧版 `deepseek-v4-flash[1M]` 格式在 settings 加载/保存时自动迁移到新格式。 +- 启动时自动清理历史 session 数据库与 Local Storage 中残留的带后缀模型名。 +- 修复 model 为空时从 `model_list` 首条无后缀 slug 回退写入 `config.toml` 的问题。 +- 修复本 profile 生成的 `model_catalog_json` 在配置未变更时不会重新生成的问题。 + +## 1.2.4 - 2026-06-08 + +- 新增 Zed 远程项目记录能力,支持维护 Codex++ 可识别的远程项目最近列表,并为远程工作区打开提供更稳定的回退策略。 +- 修复供应商同步在存在多条 `session_meta` 记录时只处理部分会话元数据的问题。 +- 修复 Windows 单实例启动保护,在默认端口被异常占用时改用更稳健的锁与端口回退逻辑,降低无法启动的概率。 +- 限制 Codex 快速服务档位只对支持的模型生效,避免不兼容模型收到无效配置。 +- 修复 macOS DMG 打包和 bundle 结构,恢复 launcher / manager 二进制重命名逻辑。 +- 补充混合登录中继模式文档说明。 +- 版本号更新到 `1.2.4`,同步 Rust workspace、Tauri、前端 package 和后端展示版本。 + +## 1.1.8 - 2026-05-26 + +- 新增上游分支 worktree 支持,可从上游仓库/分支创建和选择独立工作区。 +- 新增上游分支列表获取、默认值处理、远端解析和 worktree 创建相关接口与测试。 +- 优化供应商同步逻辑,保留 rollout 文件 mtime,减少同步后不必要的会话状态变化。 +- 新增独立的「工具与插件」页面,用于统一管理 Codex++ / Codex 的 MCP、skills、plugins,不再绑定到单个供应商。 +- 切换供应商时会合并当前启用的工具与插件配置,同时避免把供应商专属配置误写入通用配置。 +- 工具与插件列表改为从当前 Codex 配置实时读取启用状态,支持直接开关和删除条目。 +- 调整通用配置提取逻辑,改为手动提取,减少自动覆盖和配置污染。 +- 修复供应商切换隔离问题,避免 `model_catalog_json`、旧 `model_provider`、历史 provider 表和旧 `auth.json` 被带到新供应商。 +- 修复纯 API 模式下 `auth.json` 没有写入 API Key 的问题,并固定供应商 provider 名称为 `CodexPlusPlus`。 +- 优化模型目录写入方式,支持与原始模型目录合并,并在预览中显示真实路径。 +- 供应商配置页新增模型插入方式、模型列表、上下文大小、压缩上下文大小、目标功能等配置项。 +- 官方模式下隐藏仅混入 API Key 场景使用的模型列表和模型插入方式。 +- 将 Base URL、API Key、上游协议移动到模型列表之前,测试模型和上下文选项收进「更多选项」。 +- 修复 `model_reasoning_effort`、`plan_mode_reasoning_effort` 重复写入导致 TOML 解析失败的问题。 +- 修复重复插件表、空配置体、布尔值解析等导致配置文件解析失败的问题。 +- 优化供应商详情页布局,保持顶部返回和提示区域固定,增大默认窗口尺寸并减少顶部缝隙。 +- 移除脚本安装时的 checksum 阻断,避免市场脚本校验不一致导致安装失败。 +- 清理关于页和状态页中不需要展示的登录、当前供应商、配置文件路径等信息。 +- 调整提示信息居中显示,避免遮挡重启按钮。 +- 更新讨论群二维码、README 说明和 macOS DMG 打包脚本。 diff --git a/Cargo.lock b/Cargo.lock index 4bcb67e91..54e3222fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -59,6 +94,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -350,10 +394,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "codex-plus-core" -version = "1.2.4" +version = "1.2.30" dependencies = [ + "aes-gcm", "anyhow", "async-trait", "base64 0.22.1", @@ -364,18 +419,21 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite", "toml 0.8.2", "toml_edit 0.22.27", + "uuid", "windows 0.58.0", + "zip", ] [[package]] name = "codex-plus-data" -version = "1.2.4" +version = "1.2.30" dependencies = [ "anyhow", "base64 0.22.1", @@ -391,7 +449,7 @@ dependencies = [ [[package]] name = "codex-plus-launcher" -version = "1.2.4" +version = "1.2.30" dependencies = [ "anyhow", "async-trait", @@ -405,13 +463,14 @@ dependencies = [ [[package]] name = "codex-plus-manager" -version = "1.2.4" +version = "1.2.30" dependencies = [ "anyhow", "async-trait", "codex-plus-core", "codex-plus-data", "directories", + "rusqlite", "serde", "serde_json", "tauri", @@ -530,6 +589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -572,6 +632,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.23.0" @@ -633,6 +702,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1190,6 +1270,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1686,6 +1776,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2261,6 +2360,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -2425,6 +2530,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2618,7 +2735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -2628,7 +2745,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -4264,6 +4390,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5469,8 +5605,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 07e3b4037..a42e5db52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,20 +8,23 @@ members = [ ] [workspace.package] -version = "1.2.4" +version = "1.2.30" edition = "2024" license = "MIT" repository = "https://github.com/BigPizzaV3/CodexPlusPlus" [workspace.dependencies] +aes-gcm = "0.10" anyhow = "1" base64 = "0.22" directories = "6" fs2 = "0.4" +futures-util = "0.3" reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls", "system-proxy"], default-features = false } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tempfile = "3" thiserror = "2" tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } @@ -31,3 +34,4 @@ toml_edit = "0.22" url = "2" uuid = { version = "1", features = ["v4"] } winresource = "0.1" +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 000000000..c5e4383cc --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,54 @@ +# HANDOVER — CodexPlusPlus + +## Active Work + +### PR #1247: guard port auto-offset for multi-user RDP + +**PR:** https://github.com/BigPizzaV3/CodexPlusPlus/pull/1247 +**Branch:** `fix/guard-port-offset` (on lennney fork) + +### What was done + +Replaced hardcoded guard port constants with dynamic resolution: + +| Before | After | +|--------|-------| +| `LAUNCHER_GUARD_PORT = 57320` | `launcher_guard_port()` — function with offset | +| `MANAGER_GUARD_PORT = 57319` | `manager_guard_port()` — function with offset | + +Resolution order: +1. `CODEX_PLUS_GUARD_PORT` — exact port override +2. `CODEX_PLUS_{LAUNCHER,MANAGER}_GUARD_PORT` — per-role override +3. `CODEX_PLUS_GUARD_PORT_OFFSET` — explicit offset (e.g. +50) +4. Windows: `USERNAME` hash mod 1000 (auto per-user isolation) +5. Other platforms: 0 (backward compatible) + +### CI Fix (2026-06-28 23:19) + +**Problem:** All 3 platform builds failed with Rust E0308 type mismatch. +**Root cause:** `.and_then(|v| v.parse::().map_err(|_| ()))` — the `.or_else()` chain maintained `Result<_, VarError>` type but `.map_err(|_| ())` produced `Result<_, ()>`. +**Fix (5f9305f):** Switched to Option chain: `.ok().and_then(|v| v.parse().ok())` +**Code review:** APPROVED ✅ +**CI:** Run 28326773669 in progress + +### Files changed + +| File | Change | +|------|--------| +| `crates/codex-plus-core/src/ports.rs` | +2 functions, +6 tests, base constants, Option chain fix | +| `apps/codex-plus-launcher/src/main.rs` | 4 references updated + test assertion | +| `apps/codex-plus-manager/src-tauri/src/lib.rs` | 5 references updated | +| `apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs` | 1 assertion updated | + +### Commits + +``` +5f9305f fix(ports): use Option chain for env var parsing to resolve type mismatch +3a4f0aa fix(guard): auto-offset guard port by USERNAME for multi-user RDP +``` + +### Next steps + +1. Wait for CI (run 28326773669) to complete — verify all 3 platforms green +2. Wait for upstream maintainer review +3. If maintainer requests changes, address them diff --git a/README.md b/README.md index a5bb9da98..9ed1b8510 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ Codex++ 是面向 Codex App 的外部增强启动器和管理工具。它不修 Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安装 `/Applications/Codex++.app` 和 `/Applications/Codex++ 管理工具.app`。 ## 赞助商 + +

+ + JOJO Code + +

+

+ JOJO Code|Codex++ 官方中转站
+ Codex++ 官方中转站,主打稳定接入和划算价格,支持 GPT-5.5、GPT-5.4、Claude Opus 4.8、Claude Opus 4.7、gpt-image-2 等模型与图像能力,适合日常开发、团队协作和长期项目工作流。 +

+ 想显示在下方?

@@ -45,10 +56,10 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 - JOJO Code + JOJO Code - JOJO Code|Codex++ 官方中转站
感谢 JOJO Code 赞助了本项目!JOJO Code 是 Codex++ 官方中转站,面向日常开发和团队协作场景,提供稳定可用的 Codex API 接入体验,适合快速接入、长期使用和项目级工作流。 + JOJO Code|Codex++ 官方中转站
感谢 JOJO Code 赞助本项目。JOJO Code 是 Codex++ 官方中转站,提供价格划算、稳定易接入的 Codex API 中转服务,支持 GPT-5.5、GPT-5.4、Claude Opus 4.8、Claude Opus 4.7、gpt-image-2 等模型与图像能力,适合日常开发、快速配置、团队协作和长期使用。 @@ -58,14 +69,6 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 AIGoCode
感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,支持多风险,国内直连,无需魔法,极速响应。AIGoCode 为 CodexPlusPlus 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! - - - - PackyCode - - - PackyCode
感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"CodexPlusPlus"优惠码,首次充值可以享受9折优惠! - @@ -82,6 +85,14 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 RunAPI
感谢 RunAPI 赞助了本项目!RunAPI 是高效稳定的 API OpenRouter 平替平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。 + + + + Cubence + + + Cubence
感谢 Cubence 对本项目的支持。Cubence 是一家致力为客户提供稳定、高效的 API 中转服务商。从 25 年 9 月运营至今,提供了 Claude Code、Codex、Gemini 等多种模型支持。Cubence 为本开源项目多用户提供了特别的专属优惠 CODEXPLUSPLUS,在首次购买时享受 8.8 折优惠! + @@ -92,65 +103,73 @@ Windows 安装包会创建桌面和开始菜单快捷方式。macOS DMG 会安 - - RawChat + + Unity2.ai - RawChat|Codex 中转站
老牌中转站,支持包月套餐。低倍率调用,高缓存命中,Pro/Plus 号池,全天专人维护。 + Unity2.ai
感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过此链接注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度。 - - VisionCoder + + iCreat API - VisionCoder 开发平台
感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。 + iCreat API
感谢 iCreat API 赞助了本项目!iCreat API 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,稳定接入官方渠道,覆盖谷歌、火山、昆仑万维、腾讯云等开白名单资源。平台集成 Anthropic、ByteDance、OpenAI、DeepSeek、Google、Minimax、Kwai 等主流供应商,提供超 60 款模型调用,并通过统一控制台支持多维度模型筛选、计费类型管理和分组权限控制。支持 Pay as you go 与余额计费,企业用户可正常开票并获得专属对接服务。 - - AIHub2API + + Codelf - AIHub2API
感谢 AIHub2API 赞助了本项目!AIHub2API 是一家稳定、高效的 API 中转服务商,专注 Codex 中转业务,提供高缓存命中、低倍率的中转服务,网络链路优化无需使用魔法,极速响应,价格低至官方原价的 1%。通过专属链接注册 AIHub2API,赠送 10 美金体验额度。 + Codelf
Codelf 是内置自主式 AI Agent 的桌面应用,也是一款完整编辑器。它支持用自然语言开发项目、整理资料、操作电脑和调用本地程序,国内可直接使用,支持多家大模型,并通过高上下文缓存命中降低使用成本。 - - 优云智算 + + 屹芸科技 - 优云智算
感谢优云智算赞助了本项目!优云智算是 UCloud 旗下 AI 云平台,主打包月、按次的高性价比国模 Agent Plan 套餐,低至 49 元/月起。同时提供官转稳定海外模型,支持接入 Claude Code、Codex 及 API 调用,支持企业高并发、7×24 技术支持、自助开票。通过此链接注册的用户,可得免费 5 元平台体验金! + 屹芸科技
屹芸科技旗下拥有九五云商发卡网、屹芸付支付系统等面向 AI 聚合赛道的收付产品,支持微信、支付宝、银联、云闪付等通道,提供低费率、D1/D0 结算、7×24 小时技术支持和企微客户专属服务群。平台通道费率稳定、结算准时,并提供高强度网站防护,帮助商户稳定开展线上销售。 - - Cubence + + 随想AI网关 - Cubence
感谢 Cubence 对本项目的支持。Cubence 是一家致力为客户提供稳定、高效的 API 中转服务商。从 25 年 9 月运营至今,提供了 Claude Code、Codex、Gemini 等多种模型支持。Cubence 为本开源项目多用户提供了特别的专属优惠 CODEXPLUSPLUS,在首次购买时享受 8.8 折优惠! + 随想AI网关
感谢随想AI网关对本项目的赞助!随想AI网关是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等中继服务,注重隐私,承诺无数据倒卖、无模型掺水,并提供透明、快速的售后支持。新账户注册每日签到送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。 - - MaoLao API + + 火山引擎 - MaoLao API
MaoLao API 是一家专注 VibeCoding 主流模型的 API 中转站,有自己的纯 Pro20X/Plus 号池,所以在低倍率的情况下还能做到低价套餐,套餐所有模型以及分组无限制!猫佬API:maolaoapi.com + 火山引擎|方舟 Agent Plan
感谢火山引擎赞助本项目!方舟 Agent Plan 模型订阅套餐集成了 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.2、Doubao-Seed-2.0 系列、Kimi-K2.7 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Agent Plan 限时 2.5 折订阅,点击链接抢购,名额有限,先到先得。For developers outside Mainland China, please click here。 - - Unity2.ai + + Smallice - Unity2.ai
感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过此链接注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度。 + Smallice|AI 中转站
感谢 Smallice 赞助本项目!Smallice 是一把钥匙,通往所有值得调用的语言模型。一个统一的 endpoint,作为你应用之下、无需多言的基础层。无论你召唤的是 Claude、GPT、Gemini 还是 DeepSeek,调用的形式从此恒等。通过此链接注册即可开始使用。 + + + + + 二狗 API + + + 二狗 API
二狗,稳如老狗的 AI API 中转站。全站 0.1x~0.2x 超低倍率,提供 Claude/GPT/Gemini 等多个国内外 100% 纯血大模型接口,顶级 IPLC 线路 + 住宅双 ISP 冗余,确保全国范围稳定低延迟访问。欢迎各位开发者、工作室注册使用。 ## 交流与支持 -欢迎加入 Codex++ 交流群(QQ群:1103050832),反馈问题、交流使用体验或提出新功能建议。 +欢迎加入 Codex++ 交流群(QQ群:830629290),反馈问题、交流使用体验或提出新功能建议。 微信群:点击这里获取最新微信群二维码。 @@ -171,10 +190,14 @@ Telegram 频道: - Tauri + React 管理工具,支持深色/浅色切换。 - 外部 CDP 注入,不改 `app.asar`,不向 Codex 安装目录写入 DLL。 - 中转注入模式:支持多个中转配置,写入 `CodexPlusPlus` provider,并可切回官方 ChatGPT 登录态。 -- 传统增强模式:插件入口解锁、特殊插件强制安装、会话删除、Markdown 导出、项目移动、Timeline 等。 +- 传统增强模式:插件市场解锁、会话删除、Markdown 导出、项目移动等。 +- 粘贴修复:从 Word 等富文本来源粘贴到 Codex composer 时只保留纯文本,避免被识别为图片/文件附件。默认关闭,启用后需重启 Codex 才生效。 + - **使用提示**:管理工具里勾选后需点「保存增强设置」按钮才会写盘,然后重启 Codex++ 才会生效。 +- Stepwise 下一步建议:在 Codex 对话页显示可拖动的 Stepwise 浮层,基于当前用户意图和最新助手回复生成后续操作建议;可配置独立 Base URL、API Key、模型、建议数量和是否点击后直接发送。 - 用户脚本独立管理,可在启动时注入自定义脚本。 - Provider 同步:启动前同步本地会话 metadata,切换供应商后旧会话仍可见。 - Zed 打开入口:识别远程 SSH 上下文后,可从 Codex 直接打开对应文件到 Zed Remote Development。 +- 按模型粒度配置上下文窗口:「模型列表」分为左右两列,左侧填模型名,右侧填上下文窗口(如 `1M`、`200K` 或 `1000000`);Codex++ 自动生成 `model_catalog_json` 并注入 `config.toml`,切换模型即生效。右侧留空则使用 Codex 默认长度。 - Upstream worktree 创建:可从 `upstream/` 创建新 worktree,创建前自动 fetch 远端分支,降低从陈旧本地 HEAD 派生导致的冲突风险。 - GitHub Release 自动更新,管理工具和静默启动器都会检测可用更新。 - Windows 单实例、无黑框启动、管理员权限清单、系统桌面路径识别。 @@ -182,17 +205,17 @@ Telegram 频道: ## 痛点与解决 -API Key 登录模式下,Codex 原生插件入口会提示需要登录 ChatGPT,导致插件功能无法正常使用: +API Key 登录模式下,Codex 原生插件市场会提示需要登录 ChatGPT,导致插件功能无法正常使用: -![API Key 模式下插件入口不可用](docs/images/pain-plugin-disabled.png) +![API Key 模式下插件市场不可用](docs/images/pain-plugin-disabled.png) Codex 原生会话列表只有归档入口,没有真正的删除按钮: ![原生会话列表缺少删除能力](docs/images/pain-no-delete-button.png) -Codex++ 启动后会解锁插件入口,并在会话列表悬停时显示删除按钮: +Codex++ 启动后会解锁插件市场能力,并在会话列表悬停时显示删除按钮: -![Codex++ 解锁插件入口并添加删除按钮](docs/images/solution-plugin-and-delete.png) +![Codex++ 解锁插件市场并添加删除按钮](docs/images/solution-plugin-and-delete.png) 顶部菜单栏会出现 `Codex++`,可以查看后端状态并打开设置面板: @@ -244,7 +267,7 @@ experimental_bearer_token = "sk-..." 增强功能在管理工具中统一开关。默认开启增强注入;关闭后不会注入 Codex++ 菜单和脚本。 -如果启用中转注入模式,插件入口解锁和强制安装不再需要,界面会提示“中转注入模式下无需开启”。会话删除、导出、移动、Timeline、推荐内容和用户脚本等增强仍可继续使用。 +如果启用中转注入模式,插件市场解锁不再需要,界面会提示“中转注入模式下无需开启”。会话删除、导出、移动、粘贴修复、推荐内容和用户脚本等增强仍可继续使用。 ## 推荐内容 @@ -267,7 +290,7 @@ Codex++ 通过 GitHub Release 发布安装包。Windows 会生成 NSIS 安装程 - Codex 配置:`~/.codex/config.toml` - Codex 登录状态:`~/.codex/auth.json` -- Codex 本地数据库:`~/.codex/state_5.sqlite` +- Codex 本地数据库:优先读取 `~/.codex/sqlite/*.db`,旧版回退到 `~/.codex/state_5.sqlite` - Codex++ 状态与日志:`~/.codex-session-delete/` - Provider 同步备份:`~/.codex/backups_state/provider-sync` diff --git a/README_EN.md b/README_EN.md index 41e50f318..5284bc815 100644 --- a/README_EN.md +++ b/README_EN.md @@ -35,6 +35,16 @@ The Windows installer creates desktop and Start Menu shortcuts. The macOS DMG in ## Sponsors +

+ + JOJO Code + +

+

+ JOJO Code | Official Codex++ Relay
+ The official Codex++ relay service, focused on stable access and cost-effective pricing. JOJO Code supports GPT-5.5, GPT-5.4, Claude Opus 4.8, Claude Opus 4.7, gpt-image-2, and more for daily development, team collaboration, and long-running project workflows. +

+

Want to be shown below?

@@ -46,10 +56,10 @@ The Windows installer creates desktop and Start Menu shortcuts. The macOS DMG in - JOJO Code + JOJO Code - JOJO Code | Official Codex++ Relay
Thanks to JOJO Code for sponsoring this project! JOJO Code is the official Codex++ relay service. It is built for daily development and team collaboration, providing stable Codex API access for quick onboarding, long-term use, and project workflows. + JOJO Code | Official Codex++ Relay
Thanks to JOJO Code for sponsoring this project. JOJO Code is the official Codex++ relay service with cost-effective pricing and stable, easy-to-configure Codex API access. It supports GPT-5.5, GPT-5.4, Claude Opus 4.8, Claude Opus 4.7, gpt-image-2, and more for daily development, quick setup, team collaboration, and continuous use. @@ -59,14 +69,6 @@ The Windows installer creates desktop and Start Menu shortcuts. The macOS DMG in AIGoCode
Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform integrating the latest Claude Code, Codex, and Gemini models, providing stable, efficient, and cost-effective AI programming services. It offers flexible subscription plans, direct access in China, no extra network setup, and fast responses. AIGoCode provides a special benefit for CodexPlusPlus users: users who register through this link can receive an extra 10% bonus credit on their first recharge. - - - - PackyCode - - - PackyCode
Thanks to PackyCode for sponsoring this project! PackyCode is a stable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides a special discount for users of this software: register through this link and enter the "CodexPlusPlus" coupon code when recharging to get 10% off your first recharge. - @@ -93,19 +95,43 @@ The Windows installer creates desktop and Start Menu shortcuts. The macOS DMG in - - RawChat + + Codelf + + + Codelf
Codelf is a desktop app with an autonomous AI Agent and a full editor. It can help users build projects, organize materials, operate local apps, and work with multiple AI model providers through natural language, with direct access in China and high context-cache efficiency. + + + + + Yiyun Technology + + + Yiyun Technology
Yiyun Technology provides payment and settlement products for AI aggregation businesses, including Jiuwu Yunshang and Yiyun Pay. It supports WeChat Pay, Alipay, UnionPay, and Cloud QuickPass channels with low rates, D1/D0 settlement, 24/7 technical support, dedicated WeCom service groups, and strong website protection for merchants. + + + + + Sui Xiang AI Gateway + + + Sui Xiang AI Gateway
Thanks to Sui Xiang AI Gateway for sponsoring this project! Sui Xiang AI Gateway is a reliable and efficient API relay service provider for Claude, Codex, Gemini, and more. It focuses on privacy, transparent service, fast support, no data resale, and no model dilution. New accounts can receive 0.5 CNY in daily check-in test credit, with 1:1 recharge credit, no subscription, and pay-as-you-go billing. + + + + + BytePlus - RawChat | Codex Relay Station
A long-running relay station with monthly plans, low-rate usage, high cache hit rates, Pro/Plus account pools, and dedicated all-day maintenance. + BytePlus ModelArk | Dola Seed
Thanks to Dola Seed for sponsoring this project! Dola Seed 2.0 is a full-modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, strong reasoning, long-task execution, tool integration, and coding capabilities, and is readily accessible through the ModelArk platform. Register via this link to get 500,000 tokens of free inference quota per model. Mainland China developers can click here. - - VisionCoder + + Smallice - VisionCoder Developer Platform
Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. + Smallice | AI Relay
Thanks to Smallice for sponsoring this project! Smallice is one key to all the language models worth calling: a unified endpoint that acts as a quiet foundation layer beneath your applications. Whether you call Claude, GPT, Gemini, or DeepSeek, the request shape stays consistent. Register through this link to get started. @@ -116,10 +142,13 @@ The Windows installer creates desktop and Start Menu shortcuts. The macOS DMG in - Tauri + React manager with dark/light theme support. - External CDP injection. No `app.asar` patching and no DLL writes into the Codex installation. - Relay injection mode with multiple relay profiles, `CodexPlusPlus` provider configuration, and a one-click switch back to official ChatGPT login mode. -- Traditional enhancement mode with plugin entry unlock, forced plugin install, session delete, Markdown export, project move, Timeline, and more. +- Traditional enhancement mode with plugin marketplace unlock, session delete, Markdown export, project move, and more. +- Paste fix: when pasting from Word or other rich-text sources into the Codex composer, only keep the plain text so Codex does not treat the clipboard content as an image or file attachment. Off by default; requires a Codex relaunch to take effect. + - **Usage note**: after toggling in the manager, click the "保存增强设置" / "Save enhancement settings" button to persist, then restart Codex++ for the change to take effect. - Independent user script management with startup injection. - Provider Sync to keep historical sessions visible after switching providers. - Zed open entry detects remote SSH context and opens the matching remote file in Zed Remote Development from Codex. +- Per-model context window configuration: the "Model list" is split into two columns, model name on the left and context window (e.g. `1M`, `200K`, or `1000000`) on the right. Codex++ auto-generates `model_catalog_json` and injects it into `config.toml`; the matching window is applied when you switch models. Leave the window empty to use Codex's default length. - Upstream worktree creation: create new worktrees from `upstream/` after fetching the remote branch, reducing conflicts caused by stale local HEAD state. - GitHub Release updates. Both the manager and silent launcher can detect available updates. - Windows single instance, no console window, administrator manifest, and system Desktop path detection. @@ -170,7 +199,7 @@ To return to the official login mode, use the clear API mode button in the Relay Enhancements are controlled in the manager. Enhancement injection is enabled by default. When disabled, Codex++ will not inject its menu or scripts. -When relay injection mode is active, plugin entry unlock and forced plugin install are unnecessary, and the UI will say so. Other enhancements, including session delete, export, move, Timeline, recommendations, and user scripts, can still be used. +When relay injection mode is active, plugin marketplace unlock is unnecessary, and the UI will say so. Other enhancements, including session delete, export, move, paste fix, recommendations, and user scripts, can still be used. ## Recommendations @@ -193,7 +222,7 @@ The manager's About page can check and start updates. When the silent launcher f - Codex config: `~/.codex/config.toml` - Codex auth state: `~/.codex/auth.json` -- Codex local database: `~/.codex/state_5.sqlite` +- Codex local database: prefers `~/.codex/sqlite/*.db`, falls back to legacy `~/.codex/state_5.sqlite` - Codex++ state and logs: `~/.codex-session-delete/` - Provider Sync backups: `~/.codex/backups_state/provider-sync` diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index f6e1fccc2..2fafb6992 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -35,7 +35,16 @@ impl Default for LauncherHooks { #[tokio::main] async fn main() -> Result<()> { - let options = parse_launch_options(std::env::args().skip(1)); + let args = std::env::args().skip(1).collect::>(); + let helper_only = args.iter().any(|arg| arg == "--helper-only"); + let options = parse_launch_options(args.iter()); + if helper_only { + let hooks = LauncherHooks::default(); + hooks.start_helper(options.helper_port).await?; + std::future::pending::<()>().await; + hooks.shutdown_helper(options.helper_port).await; + return Ok(()); + } let Some(_guard) = acquire_single_instance_guard(options.debug_port)? else { activate_existing_codex_app(&options).await?; return Ok(()); @@ -83,7 +92,7 @@ fn acquire_single_instance_guard_with_retry( .with_context(|| { format!( "failed to acquire launcher guard port {}", - codex_plus_core::ports::LAUNCHER_GUARD_PORT + codex_plus_core::ports::launcher_guard_port() ) }) .map(Some), @@ -93,7 +102,7 @@ fn acquire_single_instance_guard_with_retry( fn try_acquire_single_instance_guard() -> std::io::Result { codex_plus_core::ports::acquire_resilient_loopback_port_guard( - codex_plus_core::ports::LAUNCHER_GUARD_PORT, + codex_plus_core::ports::launcher_guard_port(), ) } @@ -101,7 +110,7 @@ fn log_launcher_guard_fallback(fallback_lock_path: &Path) { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "launcher.guard_fallback", json!({ - "requested_guard_port": codex_plus_core::ports::LAUNCHER_GUARD_PORT, + "requested_guard_port": codex_plus_core::ports::launcher_guard_port(), "fallback_lock_path": fallback_lock_path }), ); @@ -129,7 +138,12 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< let settings = hooks.load_settings().await?; let app_dir = hooks.resolve_app_dir(options.app_dir.as_deref(), &settings)?; let launch_result = hooks - .launch_codex(&app_dir, options.debug_port, &settings.codex_extra_args) + .launch_codex( + &app_dir, + options.debug_port, + &settings, + &settings.codex_extra_args, + ) .await; if settings.enhancements_enabled { hooks.start_helper(options.helper_port).await?; @@ -180,7 +194,7 @@ fn log_launcher_already_running(debug_port: u16) { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "launcher.already_running", json!({ - "guard_port": codex_plus_core::ports::LAUNCHER_GUARD_PORT, + "guard_port": codex_plus_core::ports::launcher_guard_port(), "debug_port": debug_port }), ); @@ -283,6 +297,20 @@ impl LaunchHooks for LauncherHooks { self.core.apply_active_relay_profile(settings).await } + async fn ensure_computer_use_config( + &self, + settings: &codex_plus_core::settings::BackendSettings, + ) -> anyhow::Result<()> { + self.core.ensure_computer_use_config(settings).await + } + + async fn ensure_plugin_marketplace_config( + &self, + settings: &codex_plus_core::settings::BackendSettings, + ) -> anyhow::Result<()> { + self.core.ensure_plugin_marketplace_config(settings).await + } + async fn start_helper(&self, helper_port: u16) -> anyhow::Result<()> { self.core.start_helper(helper_port).await } @@ -291,10 +319,11 @@ impl LaunchHooks for LauncherHooks { &self, app_dir: &Path, debug_port: u16, + settings: &codex_plus_core::settings::BackendSettings, extra_args: &[String], ) -> anyhow::Result { self.core - .launch_codex(app_dir, debug_port, extra_args) + .launch_codex(app_dir, debug_port, settings, extra_args) .await } @@ -324,6 +353,13 @@ impl LaunchHooks for LauncherHooks { self.core.inject(debug_port, helper_port).await } + async fn start_computer_use_guard_watchdog( + &self, + settings: &codex_plus_core::settings::BackendSettings, + ) -> anyhow::Result<()> { + self.core.start_computer_use_guard_watchdog(settings).await + } + async fn write_status(&self, status: &str) { self.core.write_status(status).await; } @@ -362,10 +398,13 @@ impl Default for LauncherDataService { #[async_trait::async_trait] impl BridgeDataService for LauncherDataService { async fn delete(&self, session: SessionRef) -> anyhow::Result { - let adapter = self.storage_adapter(); - tokio::task::spawn_blocking(move || adapter.delete_local(&session)) - .await - .map_err(|error| anyhow::anyhow!("delete task failed: {error}")) + let db_paths = self.candidate_db_paths(); + let backup_store = codex_plus_data::BackupStore::new(self.backup_dir.clone()); + tokio::task::spawn_blocking(move || { + codex_plus_data::delete_local_from_paths(db_paths, backup_store, &session) + }) + .await + .map_err(|error| anyhow::anyhow!("delete task failed: {error}")) } async fn undo(&self, undo_token: String) -> anyhow::Result { @@ -376,11 +415,12 @@ impl BridgeDataService for LauncherDataService { } async fn export_markdown(&self, session: SessionRef) -> anyhow::Result { - let export_service = - codex_plus_data::MarkdownExportService::new(Some(self.db_path.clone())); - tokio::task::spawn_blocking(move || export_service.export(&session)) - .await - .map_err(|error| anyhow::anyhow!("export markdown task failed: {error}")) + let db_paths = self.candidate_db_paths(); + tokio::task::spawn_blocking(move || { + codex_plus_data::export_markdown_from_paths(db_paths, &session) + }) + .await + .map_err(|error| anyhow::anyhow!("export markdown task failed: {error}")) } async fn thread_usage_history(&self, session: SessionRef) -> anyhow::Result { @@ -405,9 +445,15 @@ impl BridgeDataService for LauncherDataService { session: SessionRef, target_cwd: String, ) -> anyhow::Result { - let adapter = self.storage_adapter(); + let db_paths = self.candidate_db_paths(); + let backup_store = codex_plus_data::BackupStore::new(self.backup_dir.clone()); tokio::task::spawn_blocking(move || { - adapter.move_codex_thread_workspace(&session, &target_cwd) + codex_plus_data::move_codex_thread_workspace_from_paths( + db_paths, + backup_store, + &session, + &target_cwd, + ) }) .await .map_err(|error| anyhow::anyhow!("move thread workspace task failed: {error}")) @@ -429,6 +475,18 @@ impl BridgeDataService for LauncherDataService { } impl LauncherDataService { + fn candidate_db_paths(&self) -> Vec { + let mut paths = vec![self.db_path.clone()]; + for path in codex_plus_core::codex_sqlite::codex_session_db_paths_from_home( + &codex_plus_core::codex_sqlite::default_codex_home_dir(), + ) { + if !paths.iter().any(|candidate| candidate == &path) { + paths.push(path); + } + } + paths + } + fn storage_adapter(&self) -> codex_plus_data::SQLiteStorageAdapter { codex_plus_data::SQLiteStorageAdapter::new( self.db_path.clone(), @@ -531,10 +589,6 @@ impl BridgeRuntimeService for LauncherRuntimeService { ) } - async fn repair_backend(&self) -> anyhow::Result { - self.backend_status().await - } - async fn codex_model_catalog(&self) -> anyhow::Result { Ok(codex_plus_core::model_catalog::read_codex_model_catalog().await) } @@ -624,13 +678,16 @@ async fn try_inject_with_context( runtime: Arc, ) -> anyhow::Result<()> { let targets = codex_plus_core::cdp::list_targets(debug_port).await?; - let target = codex_plus_core::cdp::pick_page_target(&targets)?; + let target = codex_plus_core::cdp::pick_injectable_codex_page_target(&targets)?; let websocket_url = target .web_socket_debugger_url .as_deref() .ok_or_else(|| anyhow::anyhow!("selected CDP target has no websocket URL"))?; runtime.set_websocket_url(websocket_url); - let script = codex_plus_core::assets::injection_script(helper_port); + let settings = codex_plus_core::settings::SettingsStore::default() + .load() + .unwrap_or_default(); + let script = codex_plus_core::assets::injection_script_with_settings(helper_port, &settings); let user_bundle = runtime .user_scripts .build_enabled_bundle() @@ -655,11 +712,7 @@ async fn try_inject_with_context( } fn default_codex_db_path() -> PathBuf { - directories::BaseDirs::new() - .map(|dirs| dirs.home_dir().to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")) - .join(".codex") - .join("state_5.sqlite") + codex_plus_core::codex_sqlite::codex_session_db_path() } fn open_url(url: &str) -> anyhow::Result<()> { @@ -695,14 +748,7 @@ fn open_url(url: &str) -> anyhow::Result<()> { } fn manager_exe_path() -> PathBuf { - let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); - let dir = exe.parent().unwrap_or_else(|| Path::new(".")); - let suffix = if cfg!(windows) { ".exe" } else { "" }; - dir.join(format!( - "{}{}", - codex_plus_core::install::MANAGER_BINARY, - suffix - )) + codex_plus_core::install::companion_binary_path(codex_plus_core::install::MANAGER_BINARY) } fn default_user_script_manager() -> UserScriptManager { @@ -763,23 +809,21 @@ mod tests { let source = include_str!("main.rs"); assert!(source.contains("acquire_single_instance_guard(options.debug_port)?")); - assert!(source.contains("LAUNCHER_GUARD_PORT")); + assert!(source.contains("launcher_guard_port")); assert!(source.contains("launcher.already_running")); } #[test] - fn existing_instance_path_starts_helper_and_ensures_injection() { - let source = include_str!("main.rs").replace("\r\n", "\n"); + fn launcher_hooks_forward_computer_use_guard_methods() { + let source = include_str!("main.rs"); - assert!(source.contains( - "async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result<()> {\n let hooks = LauncherHooks::default();" - )); - assert!(source.contains("hooks.start_helper(options.helper_port).await?")); - assert!( - source - .contains("hooks.ensure_injection(options.debug_port, options.helper_port).await") - ); - assert!(source.contains("injection_ready")); + assert!(source.contains("async fn ensure_computer_use_config")); + assert!(source.contains("self.core.ensure_computer_use_config(settings).await")); + assert!(source.contains("async fn ensure_plugin_marketplace_config")); + assert!(source.contains("self.core.ensure_plugin_marketplace_config(settings).await")); + assert!(source.contains("async fn start_computer_use_guard_watchdog")); + assert!(source.contains("self.core")); + assert!(source.contains(".start_computer_use_guard_watchdog(settings)")); } #[test] diff --git a/apps/codex-plus-manager/package-lock.json b/apps/codex-plus-manager/package-lock.json index 558818e2c..9f1adad5d 100644 --- a/apps/codex-plus-manager/package-lock.json +++ b/apps/codex-plus-manager/package-lock.json @@ -1,16 +1,18 @@ { "name": "codex-plus-manager", - "version": "1.2.4", + "version": "1.2.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-plus-manager", - "version": "1.2.4", + "version": "1.2.30", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@radix-ui/react-slot": "^1.1.2", "@tauri-apps/api": "^2.0.0", "@tauri-apps/cli": "^2.0.0", @@ -784,6 +786,24 @@ "node": ">=18" } }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/apps/codex-plus-manager/package.json b/apps/codex-plus-manager/package.json index 652f630e0..33164e4ab 100644 --- a/apps/codex-plus-manager/package.json +++ b/apps/codex-plus-manager/package.json @@ -1,6 +1,6 @@ { "name": "codex-plus-manager", - "version": "1.2.4", + "version": "1.2.30", "private": true, "type": "module", "scripts": { @@ -14,6 +14,8 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@radix-ui/react-slot": "^1.1.2", "@tauri-apps/api": "^2.0.0", "@tauri-apps/cli": "^2.0.0", diff --git a/apps/codex-plus-manager/src-tauri/Cargo.toml b/apps/codex-plus-manager/src-tauri/Cargo.toml index 184337a2a..824e7c894 100644 --- a/apps/codex-plus-manager/src-tauri/Cargo.toml +++ b/apps/codex-plus-manager/src-tauri/Cargo.toml @@ -21,11 +21,12 @@ codex-plus-data = { path = "../../../crates/codex-plus-data" } directories.workspace = true serde.workspace = true serde_json.workspace = true -tauri = { version = "2", features = ["custom-protocol"] } +tauri = { version = "2", features = ["custom-protocol", "tray-icon"] } tauri-plugin-dialog = "2" [build-dependencies] tauri-build = { version = "2", features = [] } [dev-dependencies] +rusqlite.workspace = true tempfile.workspace = true diff --git a/apps/codex-plus-manager/src-tauri/icons/icon.png b/apps/codex-plus-manager/src-tauri/icons/icon.png index a8952aac3..91bc9f530 100644 Binary files a/apps/codex-plus-manager/src-tauri/icons/icon.png and b/apps/codex-plus-manager/src-tauri/icons/icon.png differ diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 349d1a4fc..7a33b39ce 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use codex_plus_core::install::SILENT_BINARY; @@ -57,10 +58,54 @@ pub struct SettingsPayload { pub user_scripts: Value, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMarketplaceRepairPayload { + pub codex_home: String, + pub marketplace_root: Option, + pub initialized: bool, + pub configured: bool, + pub needs_repair: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMarketplaceStatusPayload { + pub codex_home: String, + pub marketplace_root: Option, + pub config_registered: bool, + pub needs_repair: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemotePluginMarketplacePayload { + pub codex_home: String, + pub marketplace_root: Option, + pub config_registered: bool, + pub needs_repair: bool, + pub plugin_count: usize, + pub skill_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CcsProvidersPayload { + pub db_path: String, + pub providers: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingProviderImportPayload { + pub pending: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalSessionsPayload { pub db_path: String, + pub db_paths: Vec, pub sessions: Vec, } @@ -83,13 +128,8 @@ pub struct DeleteLocalSessionRequest { pub session_id: String, #[serde(default)] pub title: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CcsProvidersPayload { - pub db_path: String, - pub providers: Vec, + #[serde(default)] + pub db_path: Option, } #[derive(Debug, Clone, Serialize)] @@ -114,6 +154,15 @@ pub struct RelayFilesPayload { pub auth_contents: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelaySwitchPayload { + pub settings: BackendSettings, + pub relay: RelayPayload, + pub settings_path: String, + pub user_scripts: Value, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SettingsBackfillPayload { @@ -148,6 +197,13 @@ pub struct RelayProfileTestPayload { pub response_preview: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StepwiseTestPayload { + pub item_count: usize, + pub error: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RelayProfileModelsPayload { @@ -155,6 +211,45 @@ pub struct RelayProfileModelsPayload { pub endpoint: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDoctorCheck { + pub id: String, + pub title: String, + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDoctorPayload { + pub profile_name: String, + pub model: String, + pub summary: String, + pub recommendation: String, + pub checks: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvConflictsPayload { + pub conflicts: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveEnvConflictsRequest { + pub names: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveEnvConflictsPayload { + pub removed: Vec, + pub backup_path: Option, + pub remaining: Vec, +} + #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveRelayFileRequest { @@ -339,8 +434,8 @@ pub fn launch_codex_plus(request: LaunchRequest) -> CommandResult { #[tauri::command] pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { - codex_plus_core::watcher::stop_launcher_processes(); - codex_plus_core::watcher::stop_codex_processes(); + codex_plus_core::watcher::stop_launcher_processes_and_wait(); + codex_plus_core::watcher::stop_codex_processes_and_wait(); spawn_codex_plus_launch(request, "Codex 已请求重启,启动任务正在后台运行。") } @@ -403,47 +498,9 @@ pub fn load_settings() -> CommandResult { #[tauri::command] pub fn save_settings(settings: BackendSettings) -> CommandResult { - let mut settings = normalize_settings_before_save(settings); - if settings.ccs_link_enabled { - if let Err(error) = codex_plus_core::ccs_import::write_linked_profiles_to_default_db( - &settings.relay_profiles, - ) { - let payload = SettingsPayload { - settings, - settings_path: codex_plus_core::paths::default_settings_path() - .to_string_lossy() - .to_string(), - user_scripts: user_script_inventory(), - }; - return failed(&format!("写回 cc-switch 供应商配置失败:{error}"), payload); - } - let active = settings.active_relay_profile(); - if !active.linked_ccs_provider_id.trim().is_empty() { - if let Err(error) = - codex_plus_core::ccs_import::set_current_codex_provider_in_default_db( - &active.linked_ccs_provider_id, - ) - { - let payload = SettingsPayload { - settings, - settings_path: codex_plus_core::paths::default_settings_path() - .to_string_lossy() - .to_string(), - user_scripts: user_script_inventory(), - }; - return failed(&format!("同步 cc-switch 当前供应商失败:{error}"), payload); - } - } - } - remove_linked_ccs_profiles_for_local_storage(&mut settings); + let settings = normalize_settings_before_save(settings); match SettingsStore::default().save(&settings) { - Ok(()) => { - let wrapper_message = refresh_cli_wrapper_after_settings_save(&settings); - settings_payload( - &format!("设置已保存。{wrapper_message}"), - "设置保存后重新读取失败", - ) - } + Ok(()) => settings_payload("设置已保存。", "设置保存后重新读取失败"), Err(error) => failed( &format!("保存设置失败:{error}"), SettingsPayload { @@ -462,14 +519,17 @@ pub fn load_ccs_providers() -> CommandResult { let db_path = codex_plus_core::ccs_import::default_ccs_db_path(); match codex_plus_core::ccs_import::list_codex_providers_from_db(&db_path) { Ok(providers) => ok( - &format!("已读取外部 Codex 供应商配置:{} 个。", providers.len()), + &format!( + "已读取 cc-switch Codex 供应商配置:{} 个。", + providers.len() + ), CcsProvidersPayload { db_path: db_path.to_string_lossy().to_string(), providers, }, ), Err(error) => failed( - &format!("读取外部供应商配置失败:{error}"), + &format!("读取 cc-switch 供应商配置失败:{error}"), CcsProvidersPayload { db_path: db_path.to_string_lossy().to_string(), providers: Vec::new(), @@ -480,60 +540,152 @@ pub fn load_ccs_providers() -> CommandResult { #[tauri::command] pub fn import_ccs_providers() -> CommandResult { - let store = SettingsStore::default(); - let mut settings = store.load().unwrap_or_default(); - let synced = match codex_plus_core::ccs_import::list_codex_providers_from_default_db() { - Ok(providers) => providers.len(), + let providers = match codex_plus_core::ccs_import::list_codex_providers_from_default_db() { + Ok(providers) => providers, Err(error) => { - let payload = settings_payload_value() - .map(|payload| payload) - .unwrap_or_else(|(_, payload)| payload); - return failed(&format!("读取外部供应商配置失败:{error}"), payload); + let payload = settings_payload_value().unwrap_or_else(|(_, payload)| payload); + return failed(&format!("读取 cc-switch 供应商配置失败:{error}"), payload); } }; - settings.ccs_link_enabled = true; - remove_linked_ccs_profiles_for_local_storage(&mut settings); - if synced == 0 { - return settings_payload("没有可联动的 cc-switch Codex 供应商配置。", "设置读取失败"); + let store = SettingsStore::default(); + let mut settings = store.load().unwrap_or_default(); + let mut existing_keys: Vec = settings + .relay_profiles + .iter() + .map(codex_plus_core::ccs_import::imported_provider_identity) + .collect(); + let mut existing_ids: Vec = settings + .relay_profiles + .iter() + .map(|profile| profile.id.clone()) + .collect(); + let mut imported = 0usize; + + for provider in providers { + let key = codex_plus_core::ccs_import::provider_identity_from_ccs(&provider); + if existing_keys.iter().any(|existing| existing == &key) { + continue; + } + let profile = codex_plus_core::ccs_import::relay_profile_from_ccs(&provider, &existing_ids); + existing_ids.push(profile.id.clone()); + existing_keys.push(key); + settings.relay_profiles.push(profile); + imported += 1; + } + + if imported == 0 { + return settings_payload("没有新的 cc-switch 供应商配置需要导入。", "设置读取失败"); } + settings = normalize_settings_before_save(settings); match store.save(&settings) { Ok(()) => settings_payload( - &format!("已开启 cc-switch 联动:{synced} 个供应商将直接从 cc-switch 读取。"), - "联动供应商配置后重新读取设置失败", + &format!("已从 cc-switch 导入供应商配置:{imported} 个。"), + "导入供应商配置后重新读取设置失败", ), Err(error) => failed( - &format!("保存外部供应商配置失败:{error}"), - settings_payload_value() - .map(|payload| payload) - .unwrap_or_else(|(_, payload)| payload), + &format!("保存 cc-switch 供应商配置失败:{error}"), + settings_payload_value().unwrap_or_else(|(_, payload)| payload), ), } } #[tauri::command] -pub fn list_local_sessions() -> CommandResult { - let db_path = codex_plus_core::relay_config::default_codex_home_dir().join("state_5.sqlite"); - let adapter = local_session_adapter(&db_path); - match adapter.list_local_sessions() { - Ok(sessions) => ok( - &format!("已读取 {} 个本地会话。", sessions.len()), - LocalSessionsPayload { - db_path: db_path.to_string_lossy().to_string(), - sessions, - }, +pub fn load_pending_provider_import() -> CommandResult { + match codex_plus_core::provider_import::load_pending_provider_import() { + Ok(pending) => ok( + "待确认供应商导入已读取。", + PendingProviderImportPayload { pending }, ), Err(error) => failed( - &format!("读取本地会话失败:{error}"), - LocalSessionsPayload { - db_path: db_path.to_string_lossy().to_string(), - sessions: Vec::new(), - }, + &format!("读取待确认供应商导入失败:{error}"), + PendingProviderImportPayload { pending: None }, + ), + } +} + +#[tauri::command] +pub fn confirm_pending_provider_import() -> CommandResult { + match codex_plus_core::provider_import::confirm_pending_provider_import() { + Ok(Some(result)) => { + let message = if result.imported { + format!("已导入供应商配置:{}。", result.profile_name) + } else { + format!("供应商配置已存在:{}。", result.profile_name) + }; + settings_payload(&message, "供应商导入后重新读取设置失败") + } + Ok(None) => settings_payload("没有待确认的供应商导入。", "设置读取失败"), + Err(error) => failed( + &format!("导入供应商配置失败:{error}"), + settings_payload_value().unwrap_or_else(|(_, payload)| payload), + ), + } +} + +#[tauri::command] +pub fn dismiss_pending_provider_import() -> CommandResult { + match codex_plus_core::provider_import::clear_pending_provider_import() { + Ok(()) => ok( + "已取消供应商导入。", + PendingProviderImportPayload { pending: None }, + ), + Err(error) => failed( + &format!("取消供应商导入失败:{error}"), + PendingProviderImportPayload { pending: None }, ), } } +#[tauri::command] +pub fn list_local_sessions() -> CommandResult { + let home = codex_plus_core::codex_sqlite::default_codex_home_dir(); + let db_paths = codex_plus_core::codex_sqlite::codex_session_db_paths_from_home(&home); + let mut sessions = Vec::new(); + let mut errors = Vec::new(); + for db_path in &db_paths { + let adapter = local_session_adapter(db_path); + match adapter.list_local_sessions() { + Ok(mut items) => sessions.append(&mut items), + Err(error) if db_path.exists() => { + errors.push(format!("{}: {error}", db_path.to_string_lossy())); + } + Err(_) => {} + } + } + sessions.sort_by(|left, right| { + right + .updated_at_ms + .cmp(&left.updated_at_ms) + .then_with(|| right.id.cmp(&left.id)) + }); + let mut seen_session_ids = std::collections::HashSet::new(); + sessions.retain(|session| seen_session_ids.insert(session.id.clone())); + let payload = LocalSessionsPayload { + db_path: db_paths + .first() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_default(), + db_paths: db_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(), + sessions, + }; + if errors.is_empty() { + ok( + &format!("已读取 {} 个本地会话。", payload.sessions.len()), + payload, + ) + } else { + failed( + &format!("读取部分本地会话失败:{}", errors.join("; ")), + payload, + ) + } +} + #[tauri::command] pub fn list_zed_remote_projects() -> CommandResult { let result = codex_plus_core::zed_remote::list_zed_remote_projects_response(&json!({})); @@ -622,13 +774,55 @@ pub fn delete_local_session(request: DeleteLocalSessionRequest) -> CommandResult }, ); } - let db_path = codex_plus_core::relay_config::default_codex_home_dir().join("state_5.sqlite"); - let adapter = local_session_adapter(&db_path); let session = SessionRef { session_id: session_id.to_string(), title: request.title, }; - let result = adapter.delete_local(&session); + let mut candidate_paths = Vec::new(); + if let Some(path) = request.db_path.as_deref() { + let path = PathBuf::from(path); + if !candidate_paths.iter().any(|candidate| candidate == &path) { + candidate_paths.push(path); + } + } + for path in codex_plus_core::codex_sqlite::codex_session_db_paths_from_home( + &codex_plus_core::codex_sqlite::default_codex_home_dir(), + ) { + if !candidate_paths.iter().any(|candidate| candidate == &path) { + candidate_paths.push(path); + } + } + log_manager_event( + "manager.delete_local_session.start", + json!({ + "session_id": session_id, + "title": session.title, + "requested_db_path": request.db_path, + "candidate_paths": candidate_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(), + }), + ); + let result = codex_plus_data::delete_local_from_paths( + candidate_paths.clone(), + codex_plus_data::BackupStore::new( + codex_plus_core::paths::default_app_state_dir().join("backups"), + ), + &session, + ); + log_manager_event( + "manager.delete_local_session.finish", + json!({ + "session_id": session_id, + "final_status": format!("{:?}", result.status), + "final_message": result.message, + "candidate_paths": candidate_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(), + }), + ); let status = if matches!( result.status, codex_plus_core::models::DeleteStatus::LocalDeleted @@ -711,8 +905,10 @@ fn normalize_settings_before_save(mut settings: BackendSettings) -> BackendSetti normalize_provider_sync_provider_list(settings.provider_sync_saved_providers); settings.provider_sync_manual_providers = normalize_provider_sync_provider_list(settings.provider_sync_manual_providers); - settings.provider_sync_last_selected_provider = - settings.provider_sync_last_selected_provider.trim().to_string(); + settings.provider_sync_last_selected_provider = settings + .provider_sync_last_selected_provider + .trim() + .to_string(); settings } @@ -732,40 +928,6 @@ fn normalize_provider_sync_provider_list(values: Vec) -> Vec { result } -fn settings_with_live_ccs_profiles(mut settings: BackendSettings) -> BackendSettings { - if !settings.ccs_link_enabled { - return settings; - } - remove_linked_ccs_profiles_for_local_storage(&mut settings); - if let Err(error) = codex_plus_core::ccs_import::sync_linked_profiles_from_default_db( - &mut settings.relay_profiles, - ) { - log_manager_event( - "manager.settings_with_live_ccs_profiles.failed", - json!({ "error": error.to_string() }), - ); - } - settings -} - -fn remove_linked_ccs_profiles_for_local_storage(settings: &mut BackendSettings) { - settings - .relay_profiles - .retain(|profile| profile.linked_ccs_provider_id.trim().is_empty()); - if !settings.ccs_link_enabled - && !settings - .relay_profiles - .iter() - .any(|profile| profile.id == settings.active_relay_id) - { - settings.active_relay_id = settings - .relay_profiles - .first() - .map(|profile| profile.id.clone()) - .unwrap_or_else(codex_plus_core::settings::default_active_relay_id); - } -} - fn relay_combined_common_config(settings: &BackendSettings) -> String { relay_join_config_sections(&[ &settings.relay_common_config_contents, @@ -822,10 +984,14 @@ fn strip_common_config_text_fallback(config_contents: &str, common_config: &str) let mut kept = Vec::new(); let mut skipping_table = false; + let mut in_root_section = true; + let mut removed_root_keys = std::collections::HashSet::new(); + let source_root_keys = toml_root_keys_before_first_table(config_contents); for line in config_contents.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_root_section = false; let header = trimmed.to_string(); skipping_table = common.table_headers.contains(&header); if skipping_table { @@ -837,9 +1003,21 @@ fn strip_common_config_text_fallback(config_contents: &str, common_config: &str) continue; } - if let Some(key) = toml_key_from_line(trimmed) { + if in_root_section && let Some(key) = toml_key_from_line(trimmed) { if common.root_keys.contains(key) { - continue; + let is_duplicate_common_key = removed_root_keys.contains(key) + || source_root_keys.contains(key) + || common.table_headers.contains("[features]") + || common + .table_headers + .contains("[marketplaces.openai-bundled]") + || common + .table_headers + .contains("[plugins.\"superpowers@openai-curated\"]"); + if is_duplicate_common_key { + removed_root_keys.insert(key.to_string()); + continue; + } } } @@ -849,6 +1027,20 @@ fn strip_common_config_text_fallback(config_contents: &str, common_config: &str) ensure_text_newline(kept.join("\n").trim_end()) } +fn toml_root_keys_before_first_table(config_contents: &str) -> std::collections::HashSet { + let mut keys = std::collections::HashSet::new(); + for line in config_contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + break; + } + if let Some(key) = toml_key_from_line(trimmed) { + keys.insert(key.to_string()); + } + } + keys +} + struct CommonConfigAnchors { root_keys: std::collections::HashSet, table_headers: std::collections::HashSet, @@ -900,11 +1092,10 @@ fn ensure_text_newline(value: &str) -> String { #[tauri::command] pub async fn load_provider_sync_targets() -> CommandResult { let settings = SettingsStore::default().load().unwrap_or_default(); - let result = tauri::async_runtime::spawn_blocking(|| { - codex_plus_data::load_provider_sync_targets(None) - }) - .await - .map_err(|error| anyhow::anyhow!("provider target discovery task failed: {error}")); + let result = + tauri::async_runtime::spawn_blocking(|| codex_plus_data::load_provider_sync_targets(None)) + .await + .map_err(|error| anyhow::anyhow!("provider target discovery task failed: {error}")); match result { Ok(mut targets) => { let manual = settings @@ -983,7 +1174,9 @@ pub async fn sync_providers_now(target_provider: Option) -> CommandResul Ok(sync) => { if is_success_sync_status(&sync.status) { persist_provider_sync_selection( - target_for_settings.as_deref().unwrap_or(&sync.target_provider), + target_for_settings + .as_deref() + .unwrap_or(&sync.target_provider), ); } ok( @@ -1182,18 +1375,194 @@ pub async fn repair_shortcuts() -> InstallActionResult { } #[tauri::command] -pub fn repair_backend() -> CommandResult { - let settings = - settings_with_live_ccs_profiles(SettingsStore::default().load().unwrap_or_default()); - let message = match codex_plus_core::cli_wrapper::ensure_cli_wrapper(&settings) { - Ok(Some(install)) => format!( - "后端已修复,命令包装器已指向 {}。", - install.real_codex.to_string_lossy() +pub fn plugin_marketplace_status() -> CommandResult { + let home = codex_plus_core::codex_home::default_codex_home_dir(); + let status = codex_plus_core::plugin_marketplace::openai_curated_marketplace_status(&home); + ok( + if status.needs_repair() { + "插件市场需要初始化或注册。" + } else { + "插件市场已可用。" + }, + PluginMarketplaceStatusPayload { + codex_home: home.to_string_lossy().to_string(), + marketplace_root: status + .marketplace_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + config_registered: status.config_registered, + needs_repair: status.needs_repair(), + }, + ) +} + +#[tauri::command] +pub async fn repair_plugin_marketplace() -> CommandResult { + let home = codex_plus_core::codex_home::default_codex_home_dir(); + match codex_plus_core::plugin_marketplace::initialize_openai_curated_marketplace_and_configure( + &home, + ) + .await + { + Ok(result) => ok( + if result.initialized { + "插件市场已从 openai/plugins 初始化并注册。" + } else if result.configured { + "已注册本地插件市场。" + } else { + "插件市场已可用,无需修复。" + }, + PluginMarketplaceRepairPayload { + codex_home: home.to_string_lossy().to_string(), + marketplace_root: + codex_plus_core::plugin_marketplace::openai_curated_marketplace_status(&home) + .marketplace_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + initialized: result.initialized, + configured: result.configured, + needs_repair: false, + }, ), - Ok(None) => "后端已修复,命令包装器当前未启用。".to_string(), - Err(error) => format!("后端修复部分失败:{error}"), + Err(error) => failed( + &format!("插件市场修复失败:{error}"), + PluginMarketplaceRepairPayload { + codex_home: home.to_string_lossy().to_string(), + marketplace_root: + codex_plus_core::plugin_marketplace::openai_curated_marketplace_status(&home) + .marketplace_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + initialized: false, + configured: false, + needs_repair: true, + }, + ), + } +} + +#[tauri::command] +pub fn remote_plugin_marketplace_status() -> CommandResult { + let home = codex_plus_core::codex_home::default_codex_home_dir(); + let status = + codex_plus_core::plugin_marketplace::openai_curated_remote_marketplace_status(&home); + let (plugin_count, skill_count) = + remote_plugin_marketplace_counts(status.marketplace_root.as_deref()); + ok( + if status.needs_repair() { + "官方远端插件缓存需要释放或注册。" + } else { + "官方远端插件缓存已可用。" + }, + RemotePluginMarketplacePayload { + codex_home: home.to_string_lossy().to_string(), + marketplace_root: status + .marketplace_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + config_registered: status.config_registered, + needs_repair: status.needs_repair(), + plugin_count, + skill_count, + }, + ) +} + +#[tauri::command] +pub fn repair_remote_plugin_marketplace() -> CommandResult { + let home = codex_plus_core::codex_home::default_codex_home_dir(); + match codex_plus_core::plugin_marketplace::ensure_openai_curated_remote_marketplace_available( + &home, + ) { + Ok(result) => { + let status = + codex_plus_core::plugin_marketplace::openai_curated_remote_marketplace_status( + &home, + ); + let (plugin_count, skill_count) = + remote_plugin_marketplace_counts(status.marketplace_root.as_deref()); + ok( + if result.initialized { + "已释放并注册内置官方远端插件缓存。" + } else if result.configured { + "已注册官方远端插件缓存。" + } else { + "官方远端插件缓存已可用,无需修复。" + }, + RemotePluginMarketplacePayload { + codex_home: home.to_string_lossy().to_string(), + marketplace_root: status + .marketplace_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + config_registered: status.config_registered, + needs_repair: status.needs_repair(), + plugin_count, + skill_count, + }, + ) + } + Err(error) => { + let status = + codex_plus_core::plugin_marketplace::openai_curated_remote_marketplace_status( + &home, + ); + let (plugin_count, skill_count) = + remote_plugin_marketplace_counts(status.marketplace_root.as_deref()); + failed( + &format!("官方远端插件缓存修复失败:{error}"), + RemotePluginMarketplacePayload { + codex_home: home.to_string_lossy().to_string(), + marketplace_root: status + .marketplace_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + config_registered: status.config_registered, + needs_repair: status.needs_repair(), + plugin_count, + skill_count, + }, + ) + } + } +} + +fn remote_plugin_marketplace_counts(root: Option<&Path>) -> (usize, usize) { + let Some(root) = root else { + return (0, 0); }; - settings_payload(&message, "修复后重新读取设置失败") + let marketplace_path = root + .join(".agents") + .join("plugins") + .join("marketplace.json"); + let plugin_count = std::fs::read_to_string(&marketplace_path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|marketplace| { + marketplace + .get("plugins") + .and_then(Value::as_array) + .map(Vec::len) + }) + .unwrap_or(0); + let skill_count = count_skill_files(&root.join("plugins")).unwrap_or(0); + (plugin_count, skill_count) +} + +fn count_skill_files(root: &Path) -> std::io::Result { + if !root.is_dir() { + return Ok(0); + } + let mut total = 0; + for entry in std::fs::read_dir(root)? { + let path = entry?.path(); + if path.is_dir() { + total += count_skill_files(&path)?; + } else if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") { + total += 1; + } + } + Ok(total) } #[tauri::command] @@ -1367,20 +1736,44 @@ pub fn reset_settings() -> CommandResult { } #[tauri::command] -pub fn relay_status() -> CommandResult { - let status = codex_plus_core::relay_config::default_relay_status(); - let message = if status.authenticated { - "已检测到 ChatGPT 登录状态。" - } else { - "未检测到 ChatGPT 登录状态,请先在 Codex/ChatGPT 中正常登录。" - }; - ok(message, relay_payload(status, None)) -} - -#[tauri::command] -pub fn read_relay_files() -> CommandResult { - let home = codex_plus_core::relay_config::default_codex_home_dir(); - match relay_files_payload_from_home(&home) { +pub fn reset_image_overlay_settings() -> CommandResult { + let store = SettingsStore::default(); + let mut settings = store.load().unwrap_or_default(); + let defaults = BackendSettings::default(); + settings.codex_app_image_overlay_enabled = defaults.codex_app_image_overlay_enabled; + settings.codex_app_image_overlay_path = defaults.codex_app_image_overlay_path; + settings.codex_app_image_overlay_opacity = defaults.codex_app_image_overlay_opacity; + let settings = normalize_settings_before_save(settings); + match store.save(&settings) { + Ok(()) => settings_payload("图片覆盖层设置已重置。", "图片覆盖层重置后重新读取失败"), + Err(error) => failed( + &format!("重置图片覆盖层失败:{error}"), + SettingsPayload { + settings, + settings_path: codex_plus_core::paths::default_settings_path() + .to_string_lossy() + .to_string(), + user_scripts: user_script_inventory(), + }, + ), + } +} + +#[tauri::command] +pub fn relay_status() -> CommandResult { + let status = codex_plus_core::relay_config::default_relay_status(); + let message = if status.authenticated { + "已检测到 ChatGPT 登录状态。" + } else { + "未检测到 ChatGPT 登录状态,请先在 Codex/ChatGPT 中正常登录。" + }; + ok(message, relay_payload(status, None)) +} + +#[tauri::command] +pub fn read_relay_files() -> CommandResult { + let home = codex_plus_core::relay_config::default_codex_home_dir(); + match relay_files_payload_from_home(&home) { Ok(payload) => ok("配置文件内容已读取。", payload), Err(error) => failed( &format!("读取配置文件失败:{error}"), @@ -1394,6 +1787,45 @@ pub fn read_relay_files() -> CommandResult { } } +#[tauri::command] +pub fn check_env_conflicts() -> CommandResult { + let conflicts = codex_plus_core::env_conflicts::detect_env_conflicts(); + let message = if conflicts.is_empty() { + "未检测到会覆盖 Codex 供应商配置的 OPENAI 环境变量。" + } else { + "检测到可能覆盖 Codex 供应商配置的 OPENAI 环境变量。" + }; + ok(message, EnvConflictsPayload { conflicts }) +} + +#[tauri::command] +pub fn remove_env_conflicts( + request: RemoveEnvConflictsRequest, +) -> CommandResult { + let backup_dir = codex_plus_core::paths::default_app_state_dir().join("backups"); + match codex_plus_core::env_conflicts::remove_env_conflicts(&request.names, backup_dir) { + Ok(result) => { + let remaining = codex_plus_core::env_conflicts::detect_env_conflicts(); + ok( + "环境变量已按确认项删除;重新启动 Codex 后生效。", + RemoveEnvConflictsPayload { + removed: result.removed, + backup_path: result.backup_path, + remaining, + }, + ) + } + Err(error) => failed( + &format!("删除环境变量失败:{error}"), + RemoveEnvConflictsPayload { + removed: Vec::new(), + backup_path: None, + remaining: codex_plus_core::env_conflicts::detect_env_conflicts(), + }, + ), + } +} + #[tauri::command] pub fn save_relay_file(request: SaveRelayFileRequest) -> CommandResult { let home = codex_plus_core::relay_config::default_codex_home_dir(); @@ -1413,6 +1845,80 @@ pub fn save_relay_file(request: SaveRelayFileRequest) -> CommandResult CommandResult { + let Ok(_guard) = relay_switch_mutex().lock() else { + let status = codex_plus_core::relay_config::default_relay_status(); + return failed( + "供应商切换锁已损坏,请重启管理器后再试。", + relay_switch_payload( + SettingsStore::default().load().unwrap_or_default(), + status, + None, + ), + ); + }; + let home = codex_plus_core::relay_config::default_codex_home_dir(); + let store = SettingsStore::default(); + let previous_active_relay_id = request.previous_active_relay_id; + let settings = normalize_settings_before_save(request.settings); + log_manager_event( + "manager.switch_relay_profile.start", + json!({ + "previousActiveRelayId": previous_active_relay_id, + "targetRelayId": settings.active_relay_id + }), + ); + match codex_plus_core::relay_switch::switch_relay_profile_in_home( + &store, + &home, + settings, + &previous_active_relay_id, + ) { + Ok(result) => { + let status = codex_plus_core::relay_config::relay_status_from_home(&home); + log_manager_event( + "manager.switch_relay_profile.ok", + json!({ + "targetRelayId": result.settings.active_relay_id, + "configured": status.configured, + "backupPath": result.backup_path.as_ref() + }), + ); + ok( + "供应商已切换。", + relay_switch_payload(result.settings, status, result.backup_path), + ) + } + Err(error) => { + let status = codex_plus_core::relay_config::relay_status_from_home(&home); + let settings = store.load().unwrap_or_default(); + log_manager_event( + "manager.switch_relay_profile.failed", + json!({ + "previousActiveRelayId": previous_active_relay_id, + "activeRelayId": settings.active_relay_id, + "error": error.to_string() + }), + ); + failed( + &format!("供应商切换失败:{error}"), + relay_switch_payload(settings, status, None), + ) + } + } +} + #[tauri::command] pub fn write_diagnostic_event(event: String, detail: Value) -> CommandResult { let event = sanitize_manager_event(&event); @@ -1671,14 +2177,21 @@ pub async fn test_relay_profile(profile: RelayProfile) -> CommandResult { let status = if result.http_status < 400 { "ok" @@ -1715,6 +2228,44 @@ pub async fn test_relay_profile(profile: RelayProfile) -> CommandResult CommandResult { + match codex_plus_core::stepwise::test_connection(&settings).await { + Ok(result) => { + let error = result + .get("error") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let item_count = result + .get("items") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or_default(); + if error.is_empty() { + ok( + &format!("Stepwise 连接正常,测试返回 {item_count} 条建议。"), + StepwiseTestPayload { item_count, error }, + ) + } else { + failed( + &format!("Stepwise 测试失败:{error}"), + StepwiseTestPayload { item_count, error }, + ) + } + } + Err(error) => failed( + &format!("Stepwise 测试失败:{error}"), + StepwiseTestPayload { + item_count: 0, + error: error.to_string(), + }, + ), + } +} + #[tauri::command] pub async fn fetch_relay_profile_models( profile: RelayProfile, @@ -1739,11 +2290,220 @@ pub async fn fetch_relay_profile_models( } } +#[tauri::command] +pub async fn diagnose_relay_profile(profile: RelayProfile) -> CommandResult { + let profile_name = if profile.name.trim().is_empty() { + "未命名供应商".to_string() + } else { + profile.name.trim().to_string() + }; + let settings = SettingsStore::default().load().unwrap_or_default(); + let test_model = if !profile.test_model.trim().is_empty() { + profile.test_model.trim().to_string() + } else { + let from_profile = codex_plus_core::relay_config::relay_profile_model(&profile); + if from_profile.trim().is_empty() { + settings.relay_test_model.trim().to_string() + } else { + from_profile + } + }; + let mut checks = Vec::new(); + + if profile.relay_mode == codex_plus_core::settings::RelayMode::Official + && !profile.official_mix_api_key + { + checks.push(ProviderDoctorCheck { + id: "config".to_string(), + title: "配置完整性".to_string(), + status: "ok".to_string(), + detail: "官方登录供应商不需要 Base URL / API Key。".to_string(), + }); + let payload = ProviderDoctorPayload { + profile_name, + model: test_model, + summary: "官方登录供应商无需 API 诊断。".to_string(), + recommendation: "如果 Codex 官方账号可用,直接使用官方登录模式即可。".to_string(), + checks, + }; + return ok("Provider Doctor:官方登录供应商无需 API 诊断。", payload); + } + + if codex_plus_core::relay_config::relay_profile_base_url(&profile) + .trim() + .is_empty() + || codex_plus_core::relay_config::relay_profile_api_key(&profile) + .trim() + .is_empty() + { + checks.push(ProviderDoctorCheck { + id: "config".to_string(), + title: "配置完整性".to_string(), + status: "failed".to_string(), + detail: "Base URL 或 API Key 为空。".to_string(), + }); + let payload = ProviderDoctorPayload { + profile_name, + model: test_model, + summary: "配置不完整,无法发起上游诊断。".to_string(), + recommendation: "先填写 Base URL 和 API Key;如果是官方账号,请切换到官方登录模式。" + .to_string(), + checks, + }; + return failed("Provider Doctor:配置不完整。", payload); + } + + checks.push(ProviderDoctorCheck { + id: "config".to_string(), + title: "配置完整性".to_string(), + status: "ok".to_string(), + detail: format!( + "{} / {}", + codex_plus_core::relay_config::relay_profile_base_url(&profile), + match profile.protocol { + codex_plus_core::settings::RelayProtocol::Responses => "Responses API", + codex_plus_core::settings::RelayProtocol::ChatCompletions => "Chat Completions", + } + ), + }); + + match codex_plus_core::model_catalog::fetch_relay_profile_model_ids(&profile).await { + Ok((models, endpoint)) => { + let contains_model = !test_model.trim().is_empty() + && models.iter().any(|model| model == test_model.trim()); + let status = if models.is_empty() { + "failed" + } else if contains_model || test_model.trim().is_empty() { + "ok" + } else { + "warning" + }; + let detail = if models.is_empty() { + format!("{endpoint} 返回 0 个模型。") + } else if contains_model || test_model.trim().is_empty() { + format!("{endpoint} 返回 {} 个模型。", models.len()) + } else { + format!( + "{endpoint} 返回 {} 个模型,但未看到测试模型「{}」。", + models.len(), + test_model + ) + }; + checks.push(ProviderDoctorCheck { + id: "models".to_string(), + title: "模型列表".to_string(), + status: status.to_string(), + detail, + }); + } + Err(error) => checks.push(ProviderDoctorCheck { + id: "models".to_string(), + title: "模型列表".to_string(), + status: "failed".to_string(), + detail: error.to_string(), + }), + } + + match codex_plus_core::relay_config::test_relay_profile(&profile, &test_model).await { + Ok(result) => { + let status = if result.http_status < 400 { + "ok" + } else { + "failed" + }; + let preview = result.response_preview.trim(); + checks.push(ProviderDoctorCheck { + id: "request".to_string(), + title: "真实请求".to_string(), + status: status.to_string(), + detail: if preview.is_empty() { + format!( + "{} 返回 HTTP {},响应内容为空。", + result.endpoint, result.http_status + ) + } else { + format!( + "{} 返回 HTTP {}:{}", + result.endpoint, result.http_status, preview + ) + }, + }); + } + Err(error) => checks.push(ProviderDoctorCheck { + id: "request".to_string(), + title: "真实请求".to_string(), + status: "failed".to_string(), + detail: error.to_string(), + }), + } + + let failed_count = checks + .iter() + .filter(|check| check.status == "failed") + .count(); + let warning_count = checks + .iter() + .filter(|check| check.status == "warning") + .count(); + let status = if failed_count > 0 { + "failed" + } else if warning_count > 0 { + "ok" + } else { + "ok" + }; + let summary = if failed_count > 0 { + format!("发现 {failed_count} 项失败,Codex 可能无法使用该供应商。") + } else if warning_count > 0 { + format!("基础连接可用,但有 {warning_count} 项需要确认。") + } else { + "供应商基础诊断通过。".to_string() + }; + let recommendation = provider_doctor_recommendation(&checks); + let message = format!("Provider Doctor:{summary}"); + CommandResult { + status: status.to_string(), + message, + payload: ProviderDoctorPayload { + profile_name, + model: test_model, + summary, + recommendation, + checks, + }, + } +} + +fn provider_doctor_recommendation(checks: &[ProviderDoctorCheck]) -> String { + if checks + .iter() + .any(|check| check.id == "config" && check.status == "failed") + { + return "先补齐 Base URL 和 API Key;如果使用官方账号,请切换到官方登录模式。".to_string(); + } + if checks + .iter() + .any(|check| check.id == "models" && check.status == "failed") + { + return "优先检查 Base URL 是否包含正确的 /v1 前缀,以及供应商是否支持 /v1/models。" + .to_string(); + } + if checks + .iter() + .any(|check| check.id == "request" && check.status == "failed") + { + return "优先检查测试模型名称、上游协议选择和 Key 权限;如果 Chat Completions 可用,请切到对应协议。".to_string(); + } + if checks.iter().any(|check| check.status == "warning") { + return "连接可用,但测试模型没有出现在模型列表里;建议改用上游返回的模型名。".to_string(); + } + "可以作为 Codex 供应商使用;如果真实对话仍失败,请查看协议代理日志里的上游响应。".to_string() +} + #[tauri::command] pub fn apply_relay_injection() -> CommandResult { let home = codex_plus_core::relay_config::default_codex_home_dir(); - let settings = - settings_with_live_ccs_profiles(SettingsStore::default().load().unwrap_or_default()); + let settings = SettingsStore::default().load().unwrap_or_default(); if !settings.relay_profiles_enabled { let status = codex_plus_core::relay_config::relay_status_from_home(&home); return failed( @@ -1753,11 +2513,15 @@ pub fn apply_relay_injection() -> CommandResult { } let relay = settings.active_relay_profile(); log_relay_apply_request("manager.apply_relay_injection", &settings, &relay); + if settings.active_aggregate_relay_profile().is_some() { + return apply_aggregate_relay_injection_to_home(&home); + } if relay_has_complete_files(&relay) { - return match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules( + return match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( &home, &relay, &relay_combined_common_config(&settings), + settings.computer_use_guard_enabled, ) { Ok(result) => { let status = codex_plus_core::relay_config::relay_status_from_home(&home); @@ -1844,11 +2608,37 @@ pub fn apply_relay_injection() -> CommandResult { } } +fn apply_aggregate_relay_injection_to_home(home: &Path) -> CommandResult { + match codex_plus_core::relay_config::apply_relay_config_to_home_with_protocol( + home, + &codex_plus_core::protocol_proxy::local_responses_proxy_base_url( + codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, + ), + "codex-plus-aggregate", + codex_plus_core::settings::RelayProtocol::Responses, + codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, + ) { + Ok(result) => { + let status = codex_plus_core::relay_config::relay_status_from_home(home); + ok( + "聚合供应商配置已写入,真实请求会由本地代理按策略轮转。", + relay_payload(status, result.backup_path), + ) + } + Err(error) => { + let status = codex_plus_core::relay_config::relay_status_from_home(home); + failed( + &format!("写入聚合供应商配置失败:{error}"), + relay_payload(status, None), + ) + } + } +} + #[tauri::command] pub fn apply_pure_api_injection() -> CommandResult { let home = codex_plus_core::relay_config::default_codex_home_dir(); - let settings = - settings_with_live_ccs_profiles(SettingsStore::default().load().unwrap_or_default()); + let settings = SettingsStore::default().load().unwrap_or_default(); if !settings.relay_profiles_enabled { let status = codex_plus_core::relay_config::relay_status_from_home(&home); return failed( @@ -1859,10 +2649,11 @@ pub fn apply_pure_api_injection() -> CommandResult { let relay = settings.active_relay_profile(); log_relay_apply_request("manager.apply_pure_api_injection", &settings, &relay); if relay_has_complete_files(&relay) { - return match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules( + return match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard( &home, &relay, &relay_combined_common_config(&settings), + settings.computer_use_guard_enabled, ) { Ok(result) => { let status = codex_plus_core::relay_config::relay_status_from_home(&home); @@ -1948,8 +2739,7 @@ pub fn apply_pure_api_injection() -> CommandResult { #[tauri::command] pub fn clear_relay_injection() -> CommandResult { let home = codex_plus_core::relay_config::default_codex_home_dir(); - let settings = - settings_with_live_ccs_profiles(SettingsStore::default().load().unwrap_or_default()); + let settings = SettingsStore::default().load().unwrap_or_default(); let relay = settings.active_relay_profile(); log_manager_event("manager.clear_relay_injection.start", json!({})); let auth_contents = (relay.relay_mode == codex_plus_core::settings::RelayMode::Official @@ -2068,17 +2858,6 @@ fn sanitize_manager_event(event: &str) -> String { } } -fn refresh_cli_wrapper_after_settings_save(settings: &BackendSettings) -> String { - match codex_plus_core::cli_wrapper::ensure_cli_wrapper(settings) { - Ok(Some(install)) => format!( - " 命令包装器已更新:{}。", - install.real_codex.to_string_lossy() - ), - Ok(None) => String::new(), - Err(error) => format!(" 但命令包装器更新失败:{error}。"), - } -} - fn relay_payload( status: codex_plus_core::relay_config::RelayStatus, backup_path: Option, @@ -2095,6 +2874,26 @@ fn relay_payload( } } +fn relay_switch_payload( + settings: BackendSettings, + status: codex_plus_core::relay_config::RelayStatus, + backup_path: Option, +) -> RelaySwitchPayload { + RelaySwitchPayload { + settings, + relay: relay_payload(status, backup_path), + settings_path: codex_plus_core::paths::default_settings_path() + .to_string_lossy() + .to_string(), + user_scripts: user_script_inventory(), + } +} + +fn relay_switch_mutex() -> &'static Mutex<()> { + static RELAY_SWITCH_LOCK: OnceLock> = OnceLock::new(); + RELAY_SWITCH_LOCK.get_or_init(|| Mutex::new(())) +} + fn empty_context_entries() -> codex_plus_core::relay_config::CodexContextEntries { codex_plus_core::relay_config::CodexContextEntries { mcp_servers: Vec::new(), @@ -2179,7 +2978,7 @@ fn settings_payload_value() -> Result Ok(SettingsPayload { - settings: settings_with_live_ccs_profiles(settings), + settings, settings_path, user_scripts: user_script_inventory(), }), @@ -2196,9 +2995,7 @@ fn settings_payload_value() -> Result SettingsPayload { SettingsPayload { - settings: settings_with_live_ccs_profiles( - SettingsStore::default().load().unwrap_or_default(), - ), + settings: SettingsStore::default().load().unwrap_or_default(), settings_path: codex_plus_core::paths::default_settings_path() .to_string_lossy() .to_string(), @@ -2577,6 +3374,66 @@ mod tests { assert!(text.contains("hasBearerToken")); } + #[test] + fn provider_doctor_recommendation_prioritizes_actionable_failures() { + let recommendation = provider_doctor_recommendation(&[ + ProviderDoctorCheck { + id: "models".to_string(), + title: "模型列表".to_string(), + status: "failed".to_string(), + detail: "上游不支持 /v1/models".to_string(), + }, + ProviderDoctorCheck { + id: "request".to_string(), + title: "真实请求".to_string(), + status: "failed".to_string(), + detail: "HTTP 404".to_string(), + }, + ]); + + assert!(recommendation.contains("/v1/models")); + } + + #[test] + fn provider_doctor_recommendation_reports_model_warning() { + let recommendation = provider_doctor_recommendation(&[ + ProviderDoctorCheck { + id: "config".to_string(), + title: "配置完整性".to_string(), + status: "ok".to_string(), + detail: "https://example.test/v1 / Responses API".to_string(), + }, + ProviderDoctorCheck { + id: "models".to_string(), + title: "模型列表".to_string(), + status: "warning".to_string(), + detail: "未看到测试模型".to_string(), + }, + ProviderDoctorCheck { + id: "request".to_string(), + title: "真实请求".to_string(), + status: "ok".to_string(), + detail: "HTTP 200".to_string(), + }, + ]); + + assert!(recommendation.contains("测试模型")); + } + + #[test] + fn aggregate_relay_injection_writes_local_proxy_without_chatgpt_auth() { + let temp = tempfile::tempdir().unwrap(); + + let result = apply_aggregate_relay_injection_to_home(temp.path()); + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert_eq!(result.status, "ok"); + assert!(result.payload.configured); + assert!(!result.payload.authenticated); + assert!(config.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(config.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); + } + #[test] fn relay_files_payload_reads_config_and_auth_contents() { let temp = tempfile::tempdir().unwrap(); @@ -2599,6 +3456,210 @@ mod tests { assert_eq!(payload.auth_contents, "{\"OPENAI_API_KEY\":\"sk-test\"}\n"); } + #[test] + fn env_conflict_commands_ignore_codex_home_and_remove_openai_vars() { + let test_openai_name = "OPENAI_CODEX_PLUS_ENV_CONFLICT_TEST"; + let previous_openai = std::env::var_os(test_openai_name); + let previous_codex_home = std::env::var_os("CODEX_HOME"); + let temp = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var(test_openai_name, "sk-test"); + std::env::set_var("CODEX_HOME", temp.path()); + } + + let check = check_env_conflicts(); + assert_eq!(check.status, "ok"); + assert!( + check + .payload + .conflicts + .iter() + .any(|item| item.name == test_openai_name) + ); + assert!( + !check + .payload + .conflicts + .iter() + .any(|item| item.name == "CODEX_HOME") + ); + + codex_plus_core::env_conflicts::remove_process_env_conflicts_for_tests( + &[test_openai_name.to_string(), "CODEX_HOME".to_string()], + codex_plus_core::paths::default_app_state_dir().join("test-backups"), + ) + .unwrap(); + assert!(std::env::var_os(test_openai_name).is_none()); + assert_eq!( + std::env::var_os("CODEX_HOME"), + Some(temp.path().as_os_str().to_os_string()) + ); + + unsafe { + match previous_openai { + Some(value) => std::env::set_var(test_openai_name, value), + None => std::env::remove_var(test_openai_name), + } + match previous_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + } + } + + #[test] + fn delete_local_session_falls_back_when_requested_db_no_longer_contains_thread() { + let temp = tempfile::tempdir().unwrap(); + let previous_codex_home = std::env::var_os("CODEX_HOME"); + let codex_home = temp.path().join("codex-home"); + let sqlite_dir = codex_home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + let stale_db = sqlite_dir.join("codex-dev.db"); + let active_db = sqlite_dir.join("state_5.sqlite"); + let rollout_path = temp.path().join("rollout.jsonl"); + std::fs::write(&rollout_path, "{\"type\":\"message\"}\n").unwrap(); + let stale = rusqlite::Connection::open(&stale_db).unwrap(); + stale + .execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, title TEXT)", + [], + ) + .unwrap(); + drop(stale); + let active = rusqlite::Connection::open(&active_db).unwrap(); + active + .execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, title TEXT)", + [], + ) + .unwrap(); + active + .execute( + "INSERT INTO threads VALUES ('t1', ?1, 'Active Thread')", + [rollout_path.to_string_lossy().to_string()], + ) + .unwrap(); + drop(active); + + unsafe { + std::env::set_var("CODEX_HOME", &codex_home); + } + let result = delete_local_session(DeleteLocalSessionRequest { + session_id: "t1".to_string(), + title: "Active Thread".to_string(), + db_path: Some(stale_db.to_string_lossy().to_string()), + }); + unsafe { + if let Some(value) = previous_codex_home { + std::env::set_var("CODEX_HOME", value); + } else { + std::env::remove_var("CODEX_HOME"); + } + } + + assert_eq!(result.status, "ok"); + assert_eq!( + result.payload.status, + codex_plus_core::models::DeleteStatus::LocalDeleted + ); + let active = rusqlite::Connection::open(&active_db).unwrap(); + assert_eq!( + active + .query_row("SELECT COUNT(*) FROM threads WHERE id = 't1'", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); + } + + #[test] + fn list_local_sessions_deduplicates_threads_across_current_and_legacy_dbs() { + let temp = tempfile::tempdir().unwrap(); + let previous_codex_home = std::env::var_os("CODEX_HOME"); + let codex_home = temp.path().join("codex-home"); + let sqlite_dir = codex_home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + let current_db = sqlite_dir.join("state_5.sqlite"); + let legacy_db = codex_home.join("state_5.sqlite"); + create_minimal_thread_db(¤t_db, "t1", "Current Copy", 100); + create_minimal_thread_db(&legacy_db, "t1", "Legacy Copy", 200); + + unsafe { + std::env::set_var("CODEX_HOME", &codex_home); + } + let result = list_local_sessions(); + restore_codex_home(previous_codex_home); + + assert_eq!(result.status, "ok"); + assert_eq!(result.payload.sessions.len(), 1); + assert_eq!(result.payload.sessions[0].id, "t1"); + assert_eq!(result.payload.sessions[0].title, "Legacy Copy"); + assert_eq!( + result.payload.sessions[0].db_path, + legacy_db.to_string_lossy() + ); + } + + #[test] + fn delete_local_session_removes_duplicate_threads_from_all_candidate_dbs() { + let temp = tempfile::tempdir().unwrap(); + let previous_codex_home = std::env::var_os("CODEX_HOME"); + let codex_home = temp.path().join("codex-home"); + let sqlite_dir = codex_home.join("sqlite"); + std::fs::create_dir_all(&sqlite_dir).unwrap(); + let current_db = sqlite_dir.join("state_5.sqlite"); + let legacy_db = codex_home.join("state_5.sqlite"); + create_minimal_thread_db(¤t_db, "t1", "Current Copy", 100); + create_minimal_thread_db(&legacy_db, "t1", "Legacy Copy", 200); + + unsafe { + std::env::set_var("CODEX_HOME", &codex_home); + } + let result = delete_local_session(DeleteLocalSessionRequest { + session_id: "t1".to_string(), + title: "Legacy Copy".to_string(), + db_path: Some(legacy_db.to_string_lossy().to_string()), + }); + restore_codex_home(previous_codex_home); + + assert_eq!(result.status, "ok"); + assert_eq!(thread_count(¤t_db, "t1"), 0); + assert_eq!(thread_count(&legacy_db, "t1"), 0); + } + + fn create_minimal_thread_db(path: &Path, id: &str, title: &str, updated_at_ms: i64) { + let db = rusqlite::Connection::open(path).unwrap(); + db.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, title TEXT, updated_at_ms INTEGER)", + [], + ) + .unwrap(); + db.execute( + "INSERT INTO threads VALUES (?1, '', ?2, ?3)", + (id, title, updated_at_ms), + ) + .unwrap(); + } + + fn thread_count(path: &Path, id: &str) -> i64 { + let db = rusqlite::Connection::open(path).unwrap(); + db.query_row("SELECT COUNT(*) FROM threads WHERE id = ?1", [id], |row| { + row.get::<_, i64>(0) + }) + .unwrap() + } + + fn restore_codex_home(previous: Option) { + unsafe { + if let Some(value) = previous { + std::env::set_var("CODEX_HOME", value); + } else { + std::env::remove_var("CODEX_HOME"); + } + } + } + #[test] fn apply_relay_profile_to_home_with_switch_rules_preserves_custom_provider_id() { let temp = tempfile::tempdir().unwrap(); @@ -2648,7 +3709,6 @@ mod tests { relay_common_config_contents: "[mcp_servers.context7]\ncommand = \"npx\"\n".to_string(), relay_profiles: vec![RelayProfile { use_common_config: false, - relay_mode: codex_plus_core::settings::RelayMode::PureApi, config_contents: "model = \"gpt-5\"\n\n[mcp_servers.context7]\ncommand = \"npx\"\n" .to_string(), ..RelayProfile::default() @@ -2680,6 +3740,41 @@ mod tests { ); } + #[test] + fn reset_image_overlay_settings_preserves_supplier_settings() { + let temp = tempfile::tempdir().unwrap(); + let settings_path = temp.path().join("settings.json"); + let previous = codex_plus_core::paths::set_settings_path_for_tests(Some(settings_path)); + + let settings = BackendSettings { + codex_app_image_overlay_enabled: true, + codex_app_image_overlay_path: "C:\\Users\\me\\Pictures\\overlay.png".to_string(), + codex_app_image_overlay_opacity: 42, + active_relay_id: "supplier-a".to_string(), + relay_profiles: vec![RelayProfile { + id: "supplier-a".to_string(), + name: "供应商 A".to_string(), + relay_mode: codex_plus_core::settings::RelayMode::PureApi, + api_key: "sk-test".to_string(), + ..RelayProfile::default() + }], + ..BackendSettings::default() + }; + SettingsStore::default().save(&settings).unwrap(); + + let result = reset_image_overlay_settings(); + codex_plus_core::paths::set_settings_path_for_tests(previous); + + assert_eq!(result.status, "ok"); + assert!(!result.payload.settings.codex_app_image_overlay_enabled); + assert_eq!(result.payload.settings.codex_app_image_overlay_path, ""); + assert_eq!(result.payload.settings.codex_app_image_overlay_opacity, 35); + assert_eq!(result.payload.settings.active_relay_id, "supplier-a"); + assert_eq!(result.payload.settings.relay_profiles.len(), 1); + assert_eq!(result.payload.settings.relay_profiles[0].id, "supplier-a"); + assert_eq!(result.payload.settings.relay_profiles[0].api_key, "sk-test"); + } + #[test] fn normalize_settings_before_save_preserves_official_profile_auth() { let settings = BackendSettings { @@ -2696,42 +3791,20 @@ mod tests { let normalized = normalize_settings_before_save(settings); + let auth_json: serde_json::Value = + serde_json::from_str(&normalized.relay_profiles[0].auth_contents).unwrap(); assert_eq!( - serde_json::from_str::(&normalized.relay_profiles[0].auth_contents) - .unwrap(), - serde_json::json!({"auth_mode":"chatgpt","tokens":{"access_token":"edited"}}) + auth_json, + serde_json::json!({ + "auth_mode": "chatgpt", + "tokens": { + "access_token": "edited" + } + }) ); assert!(normalized.relay_profiles[0].config_contents.is_empty()); } - #[test] - fn remove_linked_ccs_profiles_for_local_storage_drops_external_profiles() { - let mut settings = BackendSettings { - ccs_link_enabled: true, - active_relay_id: "ccs-one".to_string(), - relay_profiles: vec![ - RelayProfile { - id: "local".to_string(), - name: "Local".to_string(), - ..RelayProfile::default() - }, - RelayProfile { - id: "ccs-one".to_string(), - linked_ccs_provider_id: "provider-one".to_string(), - name: "External".to_string(), - ..RelayProfile::default() - }, - ], - ..BackendSettings::default() - }; - - remove_linked_ccs_profiles_for_local_storage(&mut settings); - - assert_eq!(settings.relay_profiles.len(), 1); - assert_eq!(settings.relay_profiles[0].id, "local"); - assert_eq!(settings.active_relay_id, "ccs-one"); - } - #[test] fn normalize_settings_before_save_strips_common_from_enabled_profile() { let settings = BackendSettings { @@ -2746,7 +3819,6 @@ enabled = true .to_string(), relay_profiles: vec![RelayProfile { use_common_config: true, - relay_mode: codex_plus_core::settings::RelayMode::PureApi, config_contents: r#"model = "gpt-5" model_reasoning_effort = "high" @@ -2783,7 +3855,6 @@ last_updated = "2026-05-25T11:52:46Z" .to_string(), relay_profiles: vec![RelayProfile { use_common_config: true, - relay_mode: codex_plus_core::settings::RelayMode::PureApi, config_contents: r#"model = "gpt-5" model_reasoning_effort = "high" diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index f5c1c185b..ae70cb725 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -1,6 +1,18 @@ pub mod commands; pub mod install; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{Manager, WindowEvent}; + +const TRAY_ID: &str = "codex_plus_tray"; + +static APP_EXITING: AtomicBool = AtomicBool::new(false); +const TRAY_MENU_SHOW: &str = "tray_show_main"; +const TRAY_MENU_QUIT: &str = "tray_quit_app"; + pub fn run() { install_panic_logger(); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( @@ -17,15 +29,21 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .setup(move |app| { let url = if show_update { - "index.html?showUpdate=1" + "/index.html?showUpdate=1" } else { - "index.html" + "/index.html" }; - tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App(url.into())) - .title("Codex++ 管理工具") - .inner_size(1180.0, 820.0) - .min_inner_size(960.0, 720.0) - .build()?; + let mut main_window_builder = + tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App(url.into())) + .title("Codex++ 管理工具") + .inner_size(1180.0, 820.0) + .min_inner_size(960.0, 720.0); + if let Some(icon) = app.default_window_icon().cloned() { + main_window_builder = main_window_builder.icon(icon)?; + } + let main_window = main_window_builder.build()?; + install_tray(app)?; + register_main_window_events(main_window); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -36,13 +54,16 @@ pub fn run() { commands::restart_codex_plus, commands::load_settings, commands::save_settings, + commands::load_ccs_providers, + commands::import_ccs_providers, + commands::load_pending_provider_import, + commands::confirm_pending_provider_import, + commands::dismiss_pending_provider_import, commands::list_local_sessions, commands::list_zed_remote_projects, commands::open_zed_remote, commands::forget_zed_remote_project, commands::delete_local_session, - commands::load_ccs_providers, - commands::import_ccs_providers, commands::load_provider_sync_targets, commands::sync_providers_now, commands::load_ads, @@ -54,7 +75,10 @@ pub fn run() { commands::install_entrypoints, commands::uninstall_entrypoints, commands::repair_shortcuts, - commands::repair_backend, + commands::plugin_marketplace_status, + commands::repair_plugin_marketplace, + commands::remote_plugin_marketplace_status, + commands::repair_remote_plugin_marketplace, commands::check_update, commands::perform_update, commands::load_watcher_state, @@ -65,8 +89,11 @@ pub fn run() { commands::read_latest_logs, commands::copy_diagnostics, commands::reset_settings, + commands::reset_image_overlay_settings, commands::relay_status, commands::read_relay_files, + commands::check_env_conflicts, + commands::remove_env_conflicts, commands::save_relay_file, commands::write_diagnostic_event, commands::backfill_relay_profile_from_live, @@ -77,10 +104,16 @@ pub fn run() { commands::delete_context_entry, commands::extract_relay_common_config, commands::test_relay_profile, + commands::diagnose_relay_profile, + commands::test_stepwise_settings, commands::fetch_relay_profile_models, + commands::switch_relay_profile, commands::apply_relay_injection, commands::apply_pure_api_injection, - commands::clear_relay_injection + commands::clear_relay_injection, + manager_exit_app, + manager_hide_to_tray, + update_tray_labels ]) .run(tauri::generate_context!()); if let Err(error) = run_result { @@ -93,6 +126,110 @@ pub fn run() { } } +fn install_tray(app: &tauri::App) -> tauri::Result<()> { + let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW, "显示主窗口", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出程序", true, None::<&str>)?; + let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?; + + let mut tray_builder = TrayIconBuilder::with_id(TRAY_ID) + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + TRAY_MENU_SHOW => { + show_main_window(app); + } + TRAY_MENU_QUIT => { + APP_EXITING.store(true, Ordering::SeqCst); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + | TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } => { + show_main_window(&tray.app_handle()); + } + _ => {} + }); + + if let Some(icon) = app.default_window_icon().cloned() { + tray_builder = tray_builder.icon(icon); + } + + let _ = tray_builder.build(app)?; + Ok(()) +} + +fn register_main_window_events(window: tauri::WebviewWindow) { + let event_window = window.clone(); + let minimized_window = event_window.clone(); + let close_event_window = event_window.clone(); + + event_window.on_window_event(move |event| match event { + WindowEvent::Resized(_) => { + if matches!(minimized_window.is_minimized(), Ok(true)) { + let _ = minimized_window.hide(); + } + } + WindowEvent::CloseRequested { api, .. } => { + if APP_EXITING.load(Ordering::SeqCst) { + return; + } + + api.prevent_close(); + let _ = close_event_window.hide(); + } + _ => {} + }); +} + +#[tauri::command] +fn manager_exit_app(app: tauri::AppHandle) { + APP_EXITING.store(true, Ordering::SeqCst); + app.exit(0); +} + +#[tauri::command] +fn manager_hide_to_tray(window: tauri::WebviewWindow) { + let _ = window.hide(); +} + +#[tauri::command] +fn update_tray_labels( + app: tauri::AppHandle, + show_label: String, + quit_label: String, + window_title: String, +) { + if let Some(tray) = app.tray_by_id(TRAY_ID) { + let show_item = MenuItem::with_id(&app, TRAY_MENU_SHOW, &show_label, true, None::<&str>); + let quit_item = MenuItem::with_id(&app, TRAY_MENU_QUIT, &quit_label, true, None::<&str>); + if let (Ok(show), Ok(quit)) = (show_item, quit_item) { + if let Ok(menu) = Menu::with_items(&app, &[&show, &quit]) { + let _ = tray.set_menu(Some(menu)); + } + } + } + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_title(&window_title); + } +} + +fn show_main_window(app_handle: &tauri::AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + fn install_panic_logger() { std::panic::set_hook(Box::new(|panic_info| { let payload = panic_info @@ -120,14 +257,14 @@ fn install_panic_logger() { fn acquire_single_instance_guard() -> Option { match codex_plus_core::ports::acquire_resilient_loopback_port_guard( - codex_plus_core::ports::MANAGER_GUARD_PORT, + codex_plus_core::ports::manager_guard_port(), ) { Ok(guard) => { if let Some(fallback_lock_path) = guard.fallback_path() { let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( "manager.guard_fallback", serde_json::json!({ - "requested_guard_port": codex_plus_core::ports::MANAGER_GUARD_PORT, + "requested_guard_port": codex_plus_core::ports::manager_guard_port(), "fallback_lock_path": fallback_lock_path }), ); @@ -138,7 +275,7 @@ fn acquire_single_instance_guard() -> Option Option Option { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.provider_import_url.pending", + serde_json::json!({ + "name": request.name, + "baseUrl": request.base_url + }), + ); + focus_existing_manager_window(); + } + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "manager.provider_import_url.failed", + serde_json::json!({ + "error": error.to_string() + }), + ); + } + } + } + } if std::env::args().any(|arg| arg == "--show-update") { unsafe { std::env::set_var("CODEX_PLUS_SHOW_UPDATE", "1"); @@ -8,3 +32,23 @@ fn main() { } codex_plus_manager_lib::run(); } + +#[cfg(windows)] +fn focus_existing_manager_window() { + let current_process_id = std::process::id(); + for process in codex_plus_core::windows_enumerate_processes() { + if process.process_id == current_process_id { + continue; + } + if process + .exe_file + .eq_ignore_ascii_case("codex-plus-plus-manager.exe") + { + let _ = codex_plus_core::windows_activate_process_window(process.process_id); + break; + } + } +} + +#[cfg(not(windows))] +fn focus_existing_manager_window() {} diff --git a/apps/codex-plus-manager/src-tauri/tauri.conf.json b/apps/codex-plus-manager/src-tauri/tauri.conf.json index 0bb3cb3a1..11fa648d6 100644 --- a/apps/codex-plus-manager/src-tauri/tauri.conf.json +++ b/apps/codex-plus-manager/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Codex++ Manager", - "version": "1.2.4", + "version": "1.2.30", "identifier": "com.bigpizzav3.codexplusplus.manager", "build": { "beforeDevCommand": "npm run vite:dev", diff --git a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs index 6c9369ca3..6ee1edf4e 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -27,10 +27,48 @@ fn manager_uses_single_instance_guard_before_starting_tauri() { .expect("read manager lib.rs"); assert!(lib_rs.contains("acquire_single_instance_guard()")); - assert!(lib_rs.contains("MANAGER_GUARD_PORT")); + assert!(lib_rs.contains("manager_guard_port")); assert!(lib_rs.contains("manager.already_running")); } +#[test] +fn manager_main_window_uses_default_window_icon_explicitly() { + let lib_rs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs")) + .expect("read manager lib.rs"); + + assert!(lib_rs.contains("main_window_builder")); + assert!(lib_rs.contains("app.default_window_icon().cloned()")); + assert!(lib_rs.contains("main_window_builder = main_window_builder.icon(icon)?")); +} + +#[test] +fn manager_close_minimizes_to_tray_without_confirmation() { + let lib_rs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs")) + .expect("read manager lib.rs"); + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let app_tsx = manifest_dir.parent().unwrap().join("src/App.tsx"); + let app_tsx = std::fs::read_to_string(&app_tsx).expect("read manager App.tsx"); + + assert!(!lib_rs.contains("MessageDialogButtons")); + assert!(!lib_rs.contains(".dialog()")); + assert!(!lib_rs.contains("manager://close-requested")); + assert!(lib_rs.contains("let _ = close_event_window.hide();")); + assert!(!app_tsx.contains("CloseConfirmDialog")); + assert!(app_tsx.contains("manager_exit_app")); + assert!(app_tsx.contains("manager_hide_to_tray")); +} + +#[test] +fn manager_queues_codexplusplus_provider_urls_for_confirmation_on_startup() { + let main_rs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/main.rs")) + .expect("read manager main.rs"); + + assert!(main_rs.contains("codexplusplus://")); + assert!(main_rs.contains("provider_import::save_pending_provider_import_from_url")); + assert!(!main_rs.contains("provider_import::import_provider_from_url")); + assert!(main_rs.contains("manager.provider_import_url.pending")); +} + #[test] fn launcher_binary_embeds_codex_icon_resource() { let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); @@ -74,6 +112,23 @@ fn windows_binaries_request_administrator_privileges() { assert!(windows_installer.contains("RequestExecutionLevel admin")); } +#[test] +fn windows_entrypoints_register_codexplusplus_url_protocol() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let windows_install = manifest_dir + .parent() + .and_then(std::path::Path::parent) + .and_then(std::path::Path::parent) + .unwrap() + .join("crates/codex-plus-core/src/install/windows.rs"); + let windows_install = + std::fs::read_to_string(&windows_install).expect("read windows install source"); + + assert!(windows_install.contains("Software\\Classes\\codexplusplus")); + assert!(windows_install.contains("URL Protocol")); + assert!(windows_install.contains("%1")); +} + #[test] fn manager_launch_button_spawns_silent_launcher_binary() { let commands_rs = @@ -156,6 +211,12 @@ fn relay_settings_keeps_profile_config_and_auth_files_isolated() { assert!(app_tsx.contains("relayProfileSwitchValidation(selectedBeforeSave)")); assert!(app_tsx.contains("缺少独立 config.toml")); assert!(app_tsx.contains("const command = relayProfileSwitchCommand(selectedAfterSave)")); + assert!(app_tsx.contains("function relayProfileSwitchCommand")); + assert!(app_tsx.contains("return \"apply_pure_api_injection\"")); + assert!(app_tsx.contains("return \"apply_relay_injection\"")); + assert!(app_tsx.contains("const createNewAggregateProfile = () =>")); + assert!(app_tsx.contains("onClick={createNewAggregateProfile}")); + assert!(app_tsx.contains("已打开聚合供应商详情")); assert!(!commands_rs.contains("缺少独立 auth.json")); assert!(commands_rs.contains("backfill_relay_profile_from_live")); assert!(commands_rs.contains("apply_relay_profile_to_home_with_switch_rules")); @@ -170,8 +231,13 @@ fn relay_context_management_is_global_not_supplier_scoped() { let styles = std::fs::read_to_string(&styles).expect("read manager styles.css"); assert!(app_tsx.contains("作为全局配置独立管理")); - assert!(app_tsx.contains("label: \"工具与插件\"")); - assert!(app_tsx.contains("title=\"Codex 工具与插件\"")); + assert!( + app_tsx.contains("label: t(\"工具与插件\")") || app_tsx.contains("label: \"工具与插件\"") + ); + assert!( + app_tsx.contains("title={t(\"Codex 工具与插件\")}") + || app_tsx.contains("title=\"Codex 工具与插件\"") + ); assert!(!app_tsx.contains("label: \"上下文配置\"")); assert!(!app_tsx.contains("title=\"上下文配置\"")); assert!(!app_tsx.contains("Codex 上下文")); @@ -241,3 +307,52 @@ fn relay_preview_deduplicates_root_keys_when_merging_common_config() { assert!(app_tsx.contains("rootSeen.add(key)")); assert!(app_tsx.contains("joinTomlSectionsRootFirst")); } + +#[test] +fn provider_presets_include_runapi() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let presets = manifest_dir.parent().unwrap().join("src/presets.ts"); + let presets = std::fs::read_to_string(&presets).expect("read manager presets.ts"); + + assert!(presets.contains("id: \"runapi\"")); + assert!(presets.contains("name: \"RunAPI\"")); + assert!(presets.contains("category: \"aggregator\"")); + assert!(presets.contains("baseUrl: \"https://runapi.co/v1\"")); +} + +#[test] +fn manager_no_longer_exposes_mobile_control() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let app_tsx = manifest_dir.parent().unwrap().join("src/App.tsx"); + let app_tsx = std::fs::read_to_string(&app_tsx).expect("read manager App.tsx"); + + assert!(!app_tsx.contains("mobileControl")); + assert!(!app_tsx.contains("手机控制")); + assert!(!app_tsx.contains("mobileRelayServers")); + assert!(!app_tsx.contains("MobileControlScreen")); +} + +#[test] +fn manager_ui_no_longer_exposes_command_wrapper_or_startup_marketplace_prompt() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let app_tsx = manifest_dir.parent().unwrap().join("src/App.tsx"); + let app_tsx = std::fs::read_to_string(&app_tsx).expect("read manager App.tsx"); + + assert!(!app_tsx.contains("启用 Codex 命令包装器")); + assert!(!app_tsx.contains("修复后端")); + assert!(!app_tsx.contains("repairBackend")); + assert!(!app_tsx.contains("await checkPluginMarketplacePrompt()")); +} + +#[test] +fn manager_update_install_keeps_visible_progress_bar() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let app_tsx = manifest_dir.parent().unwrap().join("src/App.tsx"); + let app_tsx = std::fs::read_to_string(&app_tsx).expect("read manager App.tsx"); + + assert!(app_tsx.contains("下载并运行安装包")); + assert!(app_tsx.contains("updateInstallProgress")); + assert!(app_tsx.contains("安装包更新进度")); + assert!(app_tsx.contains("completedTitle={t(\"上次更新结果\")}")); + assert!(app_tsx.contains("progress={updateInstallProgress}")); +} diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index e1fafe1a0..0d9ac577e 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -15,6 +15,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { ArrowLeft, @@ -29,8 +30,8 @@ import { ExternalLink, Hammer, KeyRound, + Languages, LayoutDashboard, - Link2, MessageCircle, FileCode2, Moon, @@ -43,13 +44,17 @@ import { Save, Settings, ShieldCheck, + ShieldAlert, + Stethoscope, Sun, TestTube, Trash2, Wrench, type LucideIcon, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { ProviderPresetSelector } from "@/components/ProviderPresetSelector"; +import type { PresetPatch } from "@/components/ProviderPresetSelector"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Badge as UiBadge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -57,6 +62,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { + mergeModelWindowRows, + modelWindowRowsFromProfile, + serializeModelWindowRows, + type ModelWindowRow, +} from "./model-windows"; +import { getLanguage, t, tf, toggleLanguage } from "@/i18n"; type Status = "ok" | "failed" | "not_implemented" | "not_checked" | string; @@ -91,6 +103,30 @@ type OverviewResult = CommandResult<{ logs_path: string; }>; +type PluginMarketplaceRepairResult = CommandResult<{ + codexHome: string; + marketplaceRoot?: string | null; + initialized: boolean; + configured: boolean; + needsRepair: boolean; +}>; + +type PluginMarketplaceStatusResult = CommandResult<{ + codexHome: string; + marketplaceRoot?: string | null; + configRegistered: boolean; + needsRepair: boolean; +}>; + +type RemotePluginMarketplaceResult = CommandResult<{ + codexHome: string; + marketplaceRoot?: string | null; + configRegistered: boolean; + needsRepair: boolean; + pluginCount: number; + skillCount: number; +}>; + type BackendSettings = { codexAppPath: string; codexExtraArgs: string[]; @@ -99,16 +135,18 @@ type BackendSettings = { providerSyncManualProviders: string[]; providerSyncLastSelectedProvider: string; relayProfilesEnabled: boolean; - ccsLinkEnabled: boolean; enhancementsEnabled: boolean; - codexAppPluginEntryUnlock: boolean; + computerUseGuardEnabled: boolean; codexAppPluginMarketplaceUnlock: boolean; - codexAppForcePluginInstall: boolean; + codexAppPluginAutoExpand: boolean; codexAppModelWhitelistUnlock: boolean; codexAppSessionDelete: boolean; codexAppMarkdownExport: boolean; + codexAppPasteFix: boolean; + codexAppForceChineseLocale: boolean; + codexAppFastStartup: boolean; codexAppProjectMove: boolean; - codexAppConversationTimeline: boolean; + codexAppThreadIdBadge: boolean; codexAppConversationView: boolean; codexAppThreadScrollRestore: boolean; codexAppZedRemoteOpen: boolean; @@ -117,28 +155,39 @@ type BackendSettings = { zedRemoteSyncToZedSettings: boolean; codexAppUpstreamWorktreeCreate: boolean; codexAppNativeMenuPlacement: boolean; + codexAppNativeMenuLocalization: boolean; codexAppServiceTierControls: boolean; + codexAppStepwiseEnabled: boolean; + codexAppStepwiseDirectSend: boolean; + codexAppStepwiseBaseUrl: string; + codexAppStepwiseApiKey: string; + codexAppStepwiseApiKeyEnv: string; + codexAppStepwiseModel: string; + codexAppStepwiseMaxItems: number; + codexAppStepwiseMaxInputChars: number; + codexAppStepwiseMaxOutputTokens: number; + codexAppStepwiseTimeoutMs: number; + codexAppImageOverlayEnabled: boolean; + codexAppImageOverlayPath: string; + codexAppImageOverlayOpacity: number; codexGoalsEnabled: boolean; launchMode: LaunchMode; relayBaseUrl: string; relayApiKey: string; relayProfiles: RelayProfile[]; + aggregateRelayProfiles: AggregateRelayProfile[]; + activeAggregateRelayId: string; relayCommonConfigContents: string; relayContextConfigContents: string; activeRelayId: string; relayTestModel: string; - cliWrapperEnabled: boolean; - cliWrapperBaseUrl: string; - cliWrapperApiKey: string; - cliWrapperApiKeyEnv: string; }; type ZedOpenStrategy = "addToFocusedWorkspace" | "reuseWindow" | "newWindow" | "default"; type LaunchMode = "patch" | "relay"; -type RelayProfile = { +export type RelayProfile = { id: string; - linkedCcsProviderId: string; name: string; model: string; baseUrl: string; @@ -156,7 +205,29 @@ type RelayProfile = { contextWindow: string; autoCompactLimit: string; modelList: string; + modelWindows: string; userAgent: string; + aggregate?: RelayAggregateConfig | null; +}; + +type RelayAggregateStrategy = "failover" | "conversationRoundRobin" | "requestRoundRobin" | "weightedRoundRobin"; +type RelayAggregateMember = { + profileId: string; + weight: number; +}; +type RelayAggregateConfig = { + strategy: RelayAggregateStrategy; + members: RelayAggregateMember[]; +}; +type AggregateRelayMember = { + relayId: string; + weight: number; +}; +type AggregateRelayProfile = { + id: string; + name: string; + strategy: RelayAggregateStrategy; + members: AggregateRelayMember[]; }; type RelayContextSelection = { @@ -183,7 +254,7 @@ type CodexContextEntries = { }; type RelayProtocol = "responses" | "chatCompletions"; -type RelayMode = "official" | "mixedApi" | "pureApi"; +type RelayMode = "official" | "mixedApi" | "pureApi" | "aggregate"; const PROTOCOL_PROXY_BASE_URL = "http://127.0.0.1:57321/v1"; const CHAT_UPSTREAM_BASE_URL_KEY = "codex_plus_chat_base_url"; const SCRIPT_MARKET_REPOSITORY_URL = "https://github.com/BigPizzaV3/CodexPlusPlusScriptMarket"; @@ -228,6 +299,8 @@ type RelayResult = CommandResult<{ backupPath: string | null; }>; +type RelayPayload = Omit; + type RelayFilesResult = CommandResult<{ configPath: string; authPath: string; @@ -243,10 +316,12 @@ type LocalSession = { archived: boolean; updatedAtMs: number | null; rolloutPath: string; + dbPath: string; }; type LocalSessionsResult = CommandResult<{ dbPath: string; + dbPaths: string[]; sessions: LocalSession[]; }>; @@ -297,6 +372,13 @@ type ExtractRelayCommonConfigResult = CommandResult<{ profileConfigContents: string; }>; +type RelaySwitchResult = CommandResult<{ + settings: BackendSettings; + settingsPath: string; + user_scripts: unknown; + relay: RelayPayload; +}>; + type SettingsBackfillResult = CommandResult<{ settings: BackendSettings; }>; @@ -307,11 +389,31 @@ type RelayProfileTestResult = CommandResult<{ responsePreview: string; }>; +type StepwiseTestResult = CommandResult<{ + itemCount: number; + error: string; +}>; + type RelayProfileModelsResult = CommandResult<{ models: string[]; endpoint: string; }>; +type ProviderDoctorCheck = { + id: string; + title: string; + status: Status; + detail: string; +}; + +type ProviderDoctorResult = CommandResult<{ + profileName: string; + model: string; + summary: string; + recommendation: string; + checks: ProviderDoctorCheck[]; +}>; + type CcsProviderImport = { sourceId: string; name: string; @@ -322,6 +424,45 @@ type CcsProviderImport = { authContents: string; }; +type CcsProvidersResult = CommandResult<{ + dbPath: string; + providers: CcsProviderImport[]; +}>; + +type ProviderImportRequest = { + name: string; + baseUrl: string; + apiKey: string; + wireApi: string; + relayMode: string; + configContents: string; + authContents: string; +}; + +type PendingProviderImportResult = CommandResult<{ + pending: ProviderImportRequest | null; +}>; + +type EnvConflict = { + name: string; + source: "process" | "user" | string; + valuePresent: boolean; +}; + +type EnvConflictsResult = CommandResult<{ + conflicts: EnvConflict[]; +}>; + +type RemoveEnvConflictsResult = CommandResult<{ + removed: Array<{ + name: string; + removedProcess: boolean; + removedUser: boolean; + }>; + backupPath: string | null; + remaining: EnvConflict[]; +}>; + type ProviderSyncPayload = { syncStatus?: string; targetProvider?: string; @@ -359,6 +500,12 @@ type ProviderSyncProgress = { result: CommandResult | null; }; +type TaskProgress = { + active: boolean; + percent: number; + message: string; +}; + type LogsResult = CommandResult<{ path: string; text: string; @@ -396,6 +543,7 @@ type AdItem = { title: string; description: string; url: string; + image?: string; highlights?: string[]; expires_at?: string; }; @@ -434,23 +582,23 @@ type ScriptMarketResult = CommandResult<{ function providerSyncProgressMessage(result: CommandResult): string { const changed = result.changedSessionFiles ?? 0; const rows = result.sqliteRowsUpdated ?? 0; - const target = result.targetProvider || "当前 provider"; + const target = result.targetProvider || t("当前 provider"); const skipped = result.skippedLockedRolloutFiles?.length ?? 0; - const skippedText = skipped ? `,跳过 ${skipped} 个占用文件` : ""; - return `已同步到 ${target}:修复 ${changed} 个会话文件,更新 ${rows} 行索引${skippedText}。`; + const skippedText = skipped ? tf(",跳过 {0} 个占用文件", [skipped]) : ""; + return tf("已同步到 {0}:修复 {1} 个会话文件,更新 {2} 行索引{3}。", [target, changed, rows, skippedText]); } const providerSyncSourceLabels: Record = { - config: "配置", - rollout: "会话", - sqlite: "索引", - manual: "手动", + config: t("配置"), + rollout: t("会话"), + sqlite: t("索引"), + manual: t("手动"), }; function providerSyncTargetLabel(target: ProviderSyncTargetOption): string { const labels = target.sources.map((source) => providerSyncSourceLabels[source]).filter(Boolean); - const current = target.isCurrentProvider ? ["当前"] : []; - return [...labels, ...current].join(" / ") || "发现"; + const current = target.isCurrentProvider ? [t("当前")] : []; + return [...labels, ...current].join(" / ") || t("发现"); } function syncMarketInstalledState(current: ScriptMarketResult | null, userScripts: UserScriptInventory): ScriptMarketResult | null { @@ -485,18 +633,18 @@ type StartupResult = CommandResult<{ type Route = "overview" | "relay" | "sessions" | "context" | "enhance" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; type Theme = "dark" | "light"; -const routes: Array<{ id: Route; label: string; icon: LucideIcon }> = [ - { id: "overview", label: "概览", icon: LayoutDashboard }, - { id: "relay", label: "供应商配置", icon: KeyRound }, - { id: "sessions", label: "会话管理", icon: MessageCircle }, - { id: "context", label: "工具与插件", icon: Network }, - { id: "enhance", label: "页面增强", icon: Hammer }, - { id: "zedRemote", label: "Zed 远程项目", icon: ExternalLink }, - { id: "userScripts", label: "脚本市场", icon: FileCode2 }, - { id: "recommendations", label: "推荐内容", icon: ExternalLink }, - { id: "maintenance", label: "安装维护", icon: Wrench }, - { id: "about", label: "关于", icon: Info }, - { id: "settings", label: "设置", icon: Settings }, +const routes: Array<{ id: Route; label: string; icon: LucideIcon; badge?: string }> = [ + { id: "overview", label: t("概览"), icon: LayoutDashboard }, + { id: "relay", label: t("供应商配置"), icon: KeyRound }, + { id: "sessions", label: t("会话管理"), icon: MessageCircle }, + { id: "context", label: t("工具与插件"), icon: Network }, + { id: "enhance", label: t("Codex增强"), icon: Hammer }, + { id: "zedRemote", label: t("Zed 远程项目"), icon: ExternalLink }, + { id: "userScripts", label: t("脚本市场"), icon: FileCode2 }, + { id: "recommendations", label: t("推荐内容"), icon: ExternalLink }, + { id: "maintenance", label: t("安装维护"), icon: Wrench }, + { id: "about", label: t("关于"), icon: Info }, + { id: "settings", label: t("设置"), icon: Settings }, ]; const defaultSettings: BackendSettings = { @@ -507,16 +655,18 @@ const defaultSettings: BackendSettings = { providerSyncManualProviders: [], providerSyncLastSelectedProvider: "", relayProfilesEnabled: true, - ccsLinkEnabled: false, enhancementsEnabled: true, - codexAppPluginEntryUnlock: true, + computerUseGuardEnabled: false, codexAppPluginMarketplaceUnlock: true, - codexAppForcePluginInstall: true, + codexAppPluginAutoExpand: true, codexAppModelWhitelistUnlock: true, codexAppSessionDelete: true, codexAppMarkdownExport: true, + codexAppPasteFix: false, + codexAppForceChineseLocale: true, + codexAppFastStartup: false, codexAppProjectMove: true, - codexAppConversationTimeline: true, + codexAppThreadIdBadge: false, codexAppConversationView: false, codexAppThreadScrollRestore: true, codexAppZedRemoteOpen: true, @@ -525,7 +675,21 @@ const defaultSettings: BackendSettings = { zedRemoteSyncToZedSettings: false, codexAppUpstreamWorktreeCreate: true, codexAppNativeMenuPlacement: true, + codexAppNativeMenuLocalization: true, codexAppServiceTierControls: false, + codexAppStepwiseEnabled: false, + codexAppStepwiseDirectSend: false, + codexAppStepwiseBaseUrl: "", + codexAppStepwiseApiKey: "", + codexAppStepwiseApiKeyEnv: "CODEX_STEPWISE_API_KEY", + codexAppStepwiseModel: "", + codexAppStepwiseMaxItems: 6, + codexAppStepwiseMaxInputChars: 6000, + codexAppStepwiseMaxOutputTokens: 500, + codexAppStepwiseTimeoutMs: 8000, + codexAppImageOverlayEnabled: false, + codexAppImageOverlayPath: "", + codexAppImageOverlayOpacity: 35, codexGoalsEnabled: false, launchMode: "patch", relayBaseUrl: "", @@ -533,8 +697,7 @@ const defaultSettings: BackendSettings = { relayProfiles: [ { id: "default", - linkedCcsProviderId: "", - name: "默认中转", + name: t("默认中转"), model: "", baseUrl: "", upstreamBaseUrl: "", @@ -551,27 +714,36 @@ const defaultSettings: BackendSettings = { contextWindow: "", autoCompactLimit: "", modelList: "", + modelWindows: "", userAgent: "", }, ], relayCommonConfigContents: "", relayContextConfigContents: "", activeRelayId: "default", + aggregateRelayProfiles: [], + activeAggregateRelayId: "", relayTestModel: "gpt-5.4-mini", - cliWrapperEnabled: false, - cliWrapperBaseUrl: "", - cliWrapperApiKey: "", - cliWrapperApiKeyEnv: "CUSTOM_OPENAI_API_KEY", }; export function App() { const [theme, setTheme] = useState(() => loadInitialTheme()); const [route, setRoute] = useState(() => loadInitialRoute()); const [notice, setNotice] = useState<{ title: string; message: string; status?: Status } | null>(null); + const [confirmDialog, setConfirmDialog] = useState<{ + title: string; + message: string; + confirmText: string; + cancelText: string; + resolve: (confirmed: boolean) => void; + } | null>(null); const [overview, setOverview] = useState(null); const [settings, setSettings] = useState(null); const [relay, setRelay] = useState(null); const [relayFiles, setRelayFiles] = useState(null); + const [envConflicts, setEnvConflicts] = useState(null); + const [ccsProviders, setCcsProviders] = useState(null); + const [pendingProviderImport, setPendingProviderImport] = useState(null); const [localSessions, setLocalSessions] = useState(null); const [zedRemoteProjects, setZedRemoteProjects] = useState(null); const [liveContextEntries, setLiveContextEntries] = useState(null); @@ -579,6 +751,11 @@ export function App() { const [diagnostics, setDiagnostics] = useState(null); const [watcher, setWatcher] = useState(null); const [update, setUpdate] = useState(null); + const [updateInstallProgress, setUpdateInstallProgress] = useState({ + active: false, + percent: 0, + message: t("尚未运行安装包更新。"), + }); const [ads, setAds] = useState(null); const [scriptMarket, setScriptMarket] = useState(null); const [launchForm, setLaunchForm] = useState({ @@ -586,16 +763,29 @@ export function App() { debugPort: "9229", helperPort: "57321", }); + const prevLaunchStatusRef = useRef(null); const [settingsForm, setSettingsForm] = useState({ ...defaultSettings }); const [providerSyncProgress, setProviderSyncProgress] = useState({ active: false, percent: 0, - message: "尚未运行历史会话修复。", + message: t("尚未运行历史会话修复。"), result: null, }); + const [pluginMarketplaceProgress, setPluginMarketplaceProgress] = useState({ + active: false, + percent: 0, + message: t("尚未运行插件市场修复。"), + }); + const [remotePluginMarketplace, setRemotePluginMarketplace] = useState(null); + const [remotePluginMarketplaceProgress, setRemotePluginMarketplaceProgress] = useState({ + active: false, + percent: 0, + message: t("尚未检查官方远端插件缓存。"), + }); const [providerSyncTargets, setProviderSyncTargets] = useState(null); const [selectedProviderSyncTarget, setSelectedProviderSyncTarget] = useState(""); const [removeOwnedData, setRemoveOwnedData] = useState(false); + const [relaySwitching, setRelaySwitching] = useState(false); const call = (command: string, args?: Record) => invoke(command, args); @@ -607,7 +797,7 @@ export function App() { try { return await task(); } catch (error) { - showNotice("调用失败", stringifyError(error), "failed"); + showNotice(t("调用失败"), stringifyError(error), "failed"); return null; } }; @@ -615,8 +805,15 @@ export function App() { const refreshOverview = async (silent = false) => { const result = await run(() => call("load_overview")); if (result) { + // 崩溃检测:进程从运行状态变为停止/失败 → 弹出通知 + const prev = prevLaunchStatusRef.current; + const current = result.latest_launch?.status; + if (prev && prev === "running" && current && (current === "stopped" || current === "failed" || current === "crashed")) { + showNotice(t("Codex 意外停止"), tf("进程状态:{0}。是否要重新启动?", [current]), "failed"); + } + prevLaunchStatusRef.current = current ?? null; setOverview(result); - if (!silent) showResultNotice("概览已检查", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("概览已检查"), result, { silentSuccess: true }); } }; @@ -630,7 +827,7 @@ export function App() { ...current, appPath: current.appPath || result.settings.codexAppPath || "", })); - if (!silent) showResultNotice("设置已加载", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("设置已加载"), result, { silentSuccess: true }); return normalized; } return null; @@ -641,7 +838,7 @@ export function App() { if (result) { setScriptMarket(result); setSettings((current) => (current ? { ...current, user_scripts: result.user_scripts } : current)); - if (!silent || !isSuccessStatus(result.status)) showResultNotice("脚本市场", result, { silentSuccess: true }); + if (!silent || !isSuccessStatus(result.status)) showResultNotice(t("脚本市场"), result, { silentSuccess: true }); } }; @@ -650,7 +847,7 @@ export function App() { if (result) { setScriptMarket(result); setSettings((current) => (current ? { ...current, user_scripts: result.user_scripts } : current)); - showResultNotice("脚本市场", result); + showResultNotice(t("脚本市场"), result); } }; @@ -659,19 +856,19 @@ export function App() { if (result) { setSettings(result); setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); - showResultNotice("本地脚本", result); + showResultNotice(t("本地脚本"), result); } }; const deleteUserScript = async (key: string) => { const script = settings?.user_scripts?.scripts?.find((item) => item.key === key); const name = script?.name || key; - if (!window.confirm(`删除脚本“${name}”?此操作会移除本地脚本文件。`)) return; + if (!window.confirm(tf("删除脚本“{0}”?此操作会移除本地脚本文件。", [name]))) return; const result = await run(() => call("delete_user_script", { key })); if (result) { setSettings(result); setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); - showResultNotice("本地脚本", result); + showResultNotice(t("本地脚本"), result); } }; @@ -679,7 +876,7 @@ export function App() { const result = await run(() => call("relay_status")); if (result) { setRelay(result); - if (!silent) showResultNotice("登录状态", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("登录状态"), result, { silentSuccess: true }); } }; @@ -687,16 +884,87 @@ export function App() { const result = await run(() => call("read_relay_files")); if (result) { setRelayFiles(result); - if (!silent) showResultNotice("配置文件", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("配置文件"), result, { silentSuccess: true }); } return result; }; + const refreshEnvConflicts = async (silent = false) => { + const result = await run(() => call("check_env_conflicts")); + if (result) { + setEnvConflicts(result); + if (!silent || !isSuccessStatus(result.status)) showResultNotice(t("环境变量检测"), result, { silentSuccess: true }); + } + return result; + }; + + const removeEnvConflicts = async (names: string[]) => { + const uniqueNames = Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))); + if (!uniqueNames.length) return; + if (!window.confirm(tf("删除这些环境变量?\n\n{0}\n\n删除前会写入备份。", [uniqueNames.join("\n")]))) return; + const result = await run(() => call("remove_env_conflicts", { request: { names: uniqueNames } })); + if (result) { + setEnvConflicts({ + status: result.status, + message: result.message, + conflicts: result.remaining, + }); + showNotice(t("环境变量清理"), result.message, result.status); + } + }; + + const refreshCcsProviders = async (silent = false) => { + const result = await run(() => call("load_ccs_providers")); + if (result) { + setCcsProviders(result); + if (!silent || !isSuccessStatus(result.status)) showResultNotice(t("cc-switch 导入"), result, { silentSuccess: true }); + } + return result; + }; + + const importCcsProviders = async () => { + const result = await run(() => call("import_ccs_providers")); + if (result) { + setSettings(result); + setSettingsForm(normalizeSettings(result.settings)); + showResultNotice(t("cc-switch 导入"), result); + await refreshCcsProviders(true); + } + }; + + const refreshPendingProviderImport = async (silent = true) => { + const result = await run(() => call("load_pending_provider_import")); + if (result) { + setPendingProviderImport(result.pending); + if (!silent && !isSuccessStatus(result.status)) showResultNotice(t("Codex++ 导入"), result, { silentSuccess: true }); + } + return result; + }; + + const confirmPendingProviderImport = async () => { + const result = await run(() => call("confirm_pending_provider_import")); + if (result) { + setPendingProviderImport(null); + setSettings(result); + setSettingsForm(normalizeSettings(result.settings)); + showResultNotice(t("Codex++ 导入"), result); + await refreshCcsProviders(true); + } + }; + + const dismissPendingProviderImport = async () => { + const result = await run(() => call("dismiss_pending_provider_import")); + if (result) { + setPendingProviderImport(null); + showResultNotice(t("Codex++ 导入"), result, { silentSuccess: true }); + } + }; + const refreshLocalSessions = async (silent = false) => { const result = await run(() => call("list_local_sessions")); if (result) { setLocalSessions(result); - if (!silent || !isSuccessStatus(result.status)) showResultNotice("会话管理", result, { silentSuccess: true }); + if (!silent || !isSuccessStatus(result.status)) showResultNotice(t("会话管理"), result, { silentSuccess: true }); } return result; }; @@ -705,7 +973,7 @@ export function App() { const result = await run(() => call("list_zed_remote_projects")); if (result) { setZedRemoteProjects(result); - if (!silent || !isSuccessStatus(result.status)) showResultNotice("Zed 远程项目", result, { silentSuccess: true }); + if (!silent || !isSuccessStatus(result.status)) showResultNotice(t("Zed 远程项目"), result, { silentSuccess: true }); } return result; }; @@ -726,7 +994,7 @@ export function App() { }), ); if (result) { - showResultNotice("Zed 远程打开", result); + showResultNotice(t("Zed 远程打开"), result); await refreshZedRemoteProjects(true); } }; @@ -735,29 +1003,82 @@ export function App() { const result = await run(() => call("forget_zed_remote_project", { id: project.id })); if (result) { setZedRemoteProjects(result); - showResultNotice("Zed 远程项目", result); + showResultNotice(t("Zed 远程项目"), result); } }; + const requestDeleteLocalSession = (session: LocalSession) => + call("delete_local_session", { + request: { sessionId: session.id, title: session.title, dbPath: session.dbPath }, + }); + + const confirmSessionDelete = (title: string, message: string) => + new Promise((resolve) => { + setConfirmDialog({ + title, + message, + confirmText: t("确认删除"), + cancelText: t("取消"), + resolve, + }); + }); + const deleteLocalSession = async (session: LocalSession) => { const title = session.title || session.id; - if (!window.confirm(`删除会话“${title}”?此操作会删除本地数据库记录和 rollout 文件,并创建备份。`)) return; - const result = await run(() => - call("delete_local_session", { - request: { sessionId: session.id, title: session.title }, - }), - ); + const confirmed = await confirmSessionDelete(t("删除会话"), tf("删除会话“{0}”?此操作会删除本地数据库记录和 rollout 文件,并创建备份。", [title])); + if (!confirmed) return; + const result = await run(() => requestDeleteLocalSession(session)); if (result) { - showResultNotice("会话删除", result); + showResultNotice(t("会话删除"), result); await refreshLocalSessions(true); } }; + const deleteLocalSessions = async (sessions: LocalSession[]) => { + const uniqueSessions = Array.from(new Map(sessions.map((session) => [session.id, session])).values()); + if (!uniqueSessions.length) { + showNotice(t("批量删除会话"), t("请先选择要删除的会话。"), "failed"); + return; + } + const preview = uniqueSessions + .slice(0, 6) + .map((session) => `- ${truncateSessionDeletePreview(session.title || session.id)}`) + .join("\n"); + const extraCount = uniqueSessions.length > 6 ? tf("\n...以及另外 {0} 个会话", [uniqueSessions.length - 6]) : ""; + const confirmed = await confirmSessionDelete( + t("批量删除会话"), + tf("删除选中的 {0} 个会话?此操作会删除本地数据库记录和 rollout 文件,并为每个会话创建备份。\n\n{1}{2}", [uniqueSessions.length, preview, extraCount]), + ); + if (!confirmed) return; + + let succeeded = 0; + const failed: string[] = []; + for (const session of uniqueSessions) { + const result = await run(() => requestDeleteLocalSession(session)); + if (result && isSuccessStatus(result.status)) { + succeeded += 1; + } else { + failed.push(session.title || session.id); + } + } + + if (failed.length) { + showNotice( + t("批量删除会话"), + tf("已删除 {0} 个,失败 {1} 个:{2}", [succeeded, failed.length, failed.slice(0, 3).map(truncateSessionDeletePreview).join(t("、"))]), + succeeded ? "ok" : "failed", + ); + } else { + showNotice(t("批量删除会话"), tf("已删除 {0} 个会话。", [succeeded]), "ok"); + } + await refreshLocalSessions(true); + }; + const refreshLiveContextEntries = async (silent = false) => { const result = await run(() => call("read_live_context_entries")); if (result) { setLiveContextEntries(result.entries); - if (!silent || !isSuccessStatus(result.status)) showResultNotice("工具与插件", result, { silentSuccess: true }); + if (!silent || !isSuccessStatus(result.status)) showResultNotice(t("工具与插件"), result, { silentSuccess: true }); } return result; }; @@ -766,7 +1087,7 @@ export function App() { const result = await run(() => call("sync_live_context_entries", { request: { settings: next } })); if (result) { setLiveContextEntries(result.entries); - if (!silent || !isSuccessStatus(result.status)) showResultNotice("工具与插件", result, { silentSuccess: true }); + if (!silent || !isSuccessStatus(result.status)) showResultNotice(t("工具与插件"), result, { silentSuccess: true }); } return result; }; @@ -775,7 +1096,7 @@ export function App() { const result = await run(() => call("read_latest_logs", { request: { lines: 240 } })); if (result) { setLogs(result); - if (!silent) showResultNotice("日志已刷新", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("日志已刷新"), result, { silentSuccess: true }); } }; @@ -783,7 +1104,7 @@ export function App() { const result = await run(() => call("copy_diagnostics")); if (result) { setDiagnostics(result); - if (!silent) showResultNotice("诊断已生成", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("诊断已生成"), result, { silentSuccess: true }); } }; @@ -791,7 +1112,7 @@ export function App() { const result = await run(() => call("load_watcher_state")); if (result) { setWatcher(result); - if (!silent) showResultNotice("Watcher 状态", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("Watcher 状态"), result, { silentSuccess: true }); } }; @@ -802,6 +1123,8 @@ export function App() { await refreshSettings(true); await refreshRelay(true); await refreshRelayFiles(true); + await refreshEnvConflicts(true); + await refreshCcsProviders(true); } if (next === "sessions") { await refreshSettings(true); @@ -837,7 +1160,7 @@ export function App() { const launch = async () => { const result = await launchCommand("launch_codex_plus"); if (result) { - showNotice("启动任务", result.message, result.status); + showNotice(t("启动任务"), result.message, result.status); await refreshOverview(true); } }; @@ -845,7 +1168,7 @@ export function App() { const restart = async () => { const result = await launchCommand("restart_codex_plus"); if (result) { - showNotice("重启 Codex++", result.message, result.status); + showNotice(t("重启 Codex++"), result.message, result.status); await refreshOverview(true); } }; @@ -863,19 +1186,107 @@ export function App() { return result; }; - const repairBackend = async () => { - const result = await run(() => call("repair_backend")); + const repairPluginMarketplace = async () => { + if (pluginMarketplaceProgress.active) return; + setPluginMarketplaceProgress({ active: true, percent: 8, message: t("正在检查本地插件市场…") }); + const progressTimer = window.setInterval(() => { + setPluginMarketplaceProgress((current) => { + if (!current.active) return current; + const nextPercent = Math.min(92, current.percent + 9); + const message = + nextPercent < 28 + ? t("正在连接 openai/plugins…") + : nextPercent < 62 + ? t("正在下载插件市场快照…") + : nextPercent < 84 + ? t("正在解压并校验插件文件…") + : t("正在写入 Codex 配置…"); + return { ...current, percent: nextPercent, message }; + }); + }, 500); + try { + const result = await run(() => call("repair_plugin_marketplace")); + if (result) { + setPluginMarketplaceProgress({ + active: false, + percent: 100, + message: result.message, + }); + showNotice(t("插件市场修复"), result.message, result.status); + } else { + setPluginMarketplaceProgress({ + active: false, + percent: 100, + message: t("插件市场修复失败,请查看错误提示后重试。"), + }); + } + } finally { + window.clearInterval(progressTimer); + } + }; + + const refreshRemotePluginMarketplace = async (silent = false) => { + const result = await run(() => call("remote_plugin_marketplace_status")); if (result) { - setSettings(result); - setSettingsForm(normalizeSettings(result.settings)); - showNotice("后端修复", result.message, result.status); + setRemotePluginMarketplace(result); + if (!silent) { + setRemotePluginMarketplaceProgress({ + active: false, + percent: 100, + message: result.message, + }); + } + if (!silent) showNotice(t("官方远端插件缓存"), result.message, result.status); + } + return result; + }; + + const repairRemotePluginMarketplace = async () => { + if (remotePluginMarketplaceProgress.active) return; + setRemotePluginMarketplaceProgress({ + active: true, + percent: 18, + message: t("正在检查内置官方远端插件缓存…"), + }); + const progressTimer = window.setInterval(() => { + setRemotePluginMarketplaceProgress((current) => { + if (!current.active) return current; + const nextPercent = Math.min(92, current.percent + 18); + const message = + nextPercent < 50 + ? t("正在释放内置远端插件快照…") + : nextPercent < 78 + ? t("正在注册官方远端插件市场…") + : t("正在刷新官方远端插件缓存状态…"); + return { ...current, percent: nextPercent, message }; + }); + }, 450); + try { + const result = await run(() => call("repair_remote_plugin_marketplace")); + if (result) { + setRemotePluginMarketplace(result); + setRemotePluginMarketplaceProgress({ + active: false, + percent: 100, + message: result.message, + }); + showNotice(t("官方远端插件缓存"), result.message, result.status); + } else { + setRemotePluginMarketplaceProgress({ + active: false, + percent: 100, + message: t("官方远端插件缓存修复失败,请查看错误提示后重试。"), + }); + } + } finally { + window.clearInterval(progressTimer); } }; const installEntrypoints = async () => { const result = await run(() => call("install_entrypoints")); if (result) { - showNotice("入口安装", result.message, result.status); + showNotice(t("入口安装"), result.message, result.status); await refreshOverview(true); } }; @@ -887,7 +1298,7 @@ export function App() { }), ); if (result) { - showNotice("入口卸载", result.message, result.status); + showNotice(t("入口卸载"), result.message, result.status); await refreshOverview(true); } }; @@ -895,7 +1306,7 @@ export function App() { const repairShortcuts = async () => { const result = await run(() => call("repair_shortcuts")); if (result) { - showNotice("快捷方式修复", result.message, result.status); + showNotice(t("快捷方式修复"), result.message, result.status); await refreshOverview(true); } }; @@ -904,7 +1315,7 @@ export function App() { const result = await run(() => call(command)); if (result) { setWatcher(result); - showNotice("Watcher 操作", result.message, result.status); + showNotice(t("Watcher 操作"), result.message, result.status); } }; @@ -913,12 +1324,13 @@ export function App() { if (result) { setUpdate(result); if (!silent || result.updateAvailable) { - showNotice("GitHub Release 检查", result.message, result.status); + showNotice(t("GitHub Release 检查"), result.message, result.status); } } }; const performUpdate = async () => { + if (updateInstallProgress.active) return; const release = update?.latestVersion && update.assetName && update.assetUrl ? { @@ -929,58 +1341,82 @@ export function App() { asset_url: update.assetUrl, } : null; - const result = await run(() => call("perform_update", { release })); - if (result) { - setUpdate(result); - showNotice("更新安装", result.message, result.status); + setUpdateInstallProgress({ + active: true, + percent: 8, + message: t("正在准备安装包下载…"), + }); + const progressTimer = window.setInterval(() => { + setUpdateInstallProgress((current) => { + if (!current.active) return current; + const nextPercent = Math.min(92, current.percent + 10); + const message = + nextPercent < 32 + ? t("正在获取 GitHub Release 信息…") + : nextPercent < 72 + ? t("正在下载安装包…") + : t("正在启动安装包…"); + return { ...current, percent: nextPercent, message }; + }); + }, 500); + try { + const result = await run(() => call("perform_update", { release })); + if (result) { + setUpdate(result); + setUpdateInstallProgress({ + active: false, + percent: result.progress ?? 100, + message: result.message, + }); + showNotice(t("更新安装"), result.message, result.status); + } else { + setUpdateInstallProgress({ + active: false, + percent: 100, + message: t("安装包更新失败,请查看错误提示后重试。"), + }); + } + } finally { + window.clearInterval(progressTimer); } }; const saveSettings = async () => { - const next = await settingsForSave(settingsForm, false); + const next = normalizeSettings(settingsForm); const result = await run(() => call("save_settings", { settings: next })); if (result) { setSettings(result); setSettingsForm(normalizeSettings(result.settings)); - showNotice("设置保存", result.message, result.status); + showNotice(t("设置保存"), result.message, result.status); } }; - const saveSettingsValue = async (next: BackendSettings, silent = true, preserveLinkedProfiles = false) => { + const saveSettingsValue = async (next: BackendSettings, silent = true) => { const normalized = normalizeSettings(next); setSettingsForm(normalized); - const settingsToSave = await settingsForSave(normalized, preserveLinkedProfiles); - const result = await run(() => call("save_settings", { settings: settingsToSave })); + const result = await run(() => call("save_settings", { settings: normalized })); if (result) { setSettings(result); setSettingsForm(normalizeSettings(result.settings)); - if (!silent || !isSuccessStatus(result.status)) showNotice("设置保存", result.message, result.status); + if (!silent || !isSuccessStatus(result.status)) showNotice(t("设置保存"), result.message, result.status); } }; - const settingsForSave = async (next: BackendSettings, preserveLinkedProfiles: boolean) => { - const normalized = normalizeSettings(next); - if (!normalized.ccsLinkEnabled || preserveLinkedProfiles) return normalized; - const refreshed = await refreshSettings(true); - if (!refreshed) return normalized; - return mergeLiveLinkedRelayProfiles(normalized, normalizeSettings(refreshed)); - }; - - const importCcsProviders = async () => { - const result = await run(() => call("import_ccs_providers")); + const resetSettings = async () => { + const result = await run(() => call("reset_settings")); if (result) { setSettings(result); setSettingsForm(normalizeSettings(result.settings)); - showResultNotice("联动 cc-switch", result); + showNotice(t("设置重置"), result.message, result.status); } }; - const resetSettings = async () => { - const result = await run(() => call("reset_settings")); + const resetImageOverlaySettings = async () => { + const result = await run(() => call("reset_image_overlay_settings")); if (result) { setSettings(result); setSettingsForm(normalizeSettings(result.settings)); - showNotice("设置重置", result.message, result.status); + showNotice(t("图片覆盖层"), result.message, result.status); } }; @@ -988,7 +1424,7 @@ export function App() { const result = await run(() => call("load_ads")); if (result) { setAds(result); - if (!silent) showResultNotice("推荐内容", result, { silentSuccess: true }); + if (!silent) showResultNotice(t("推荐内容"), result, { silentSuccess: true }); } }; @@ -1004,7 +1440,7 @@ export function App() { targets[0]?.id || "openai"; setSelectedProviderSyncTarget((current) => (targets.some((target) => target.id === current) ? current : preferred)); - if (!silent && !isSuccessStatus(result.status)) showNotice("Provider 同步目标", result.message, result.status); + if (!silent && !isSuccessStatus(result.status)) showNotice(t("Provider 同步目标"), result.message, result.status); } return result; }; @@ -1014,7 +1450,7 @@ export function App() { setProviderSyncProgress({ active: true, percent: 12, - message: selectedProviderSyncTarget ? `正在同步到 ${selectedProviderSyncTarget}…` : "正在扫描历史会话与索引…", + message: selectedProviderSyncTarget ? tf("正在同步到 {0}…", [selectedProviderSyncTarget]) : t("正在扫描历史会话与索引…"), result: null, }); const progressTimer = window.setInterval(() => { @@ -1023,7 +1459,7 @@ export function App() { return { ...current, percent: Math.min(88, current.percent + 8), - message: current.percent < 40 ? "正在检查会话 provider 标记…" : "正在写入修复与备份…", + message: current.percent < 40 ? t("正在检查会话 provider 标记…") : t("正在写入修复与备份…"), }; }); }, 350); @@ -1050,12 +1486,12 @@ export function App() { setSettingsForm(next); } await refreshProviderSyncTargets(true); - showNotice("历史会话修复", result.message, result.status); + showNotice(t("历史会话修复"), result.message, result.status); } else { setProviderSyncProgress({ active: false, percent: 100, - message: "历史会话修复失败,请查看错误提示后重试。", + message: t("历史会话修复失败,请查看错误提示后重试。"), result: null, }); } @@ -1070,7 +1506,7 @@ export function App() { setSettings(settingsResult); setSettingsForm(normalizeSettings(settingsResult.settings)); if (!isSuccessStatus(settingsResult.status)) { - showNotice("设置保存", settingsResult.message, settingsResult.status); + showNotice(t("设置保存"), settingsResult.message, settingsResult.status); return false; } } else { @@ -1080,7 +1516,7 @@ export function App() { if (result) { setRelay(result); await refreshRelayFiles(true); - if (!silent || !isSuccessStatus(result.status)) showNotice("官方混入 API Key", result.message, result.status); + if (!silent || !isSuccessStatus(result.status)) showNotice(t("官方混入 API Key"), result.message, result.status); } return !!result && isSuccessStatus(result.status) && result.configured; }; @@ -1092,7 +1528,7 @@ export function App() { if (result) { setSettings(result); setSettingsForm(normalizeSettings(result.settings)); - if (!silent) showNotice("页面增强模式", result.message, result.status); + if (!silent) showNotice(t("Codex增强模式"), result.message, result.status); } return result; }; @@ -1103,7 +1539,7 @@ export function App() { setSettings(settingsResult); setSettingsForm(normalizeSettings(settingsResult.settings)); if (!isSuccessStatus(settingsResult.status)) { - showNotice("设置保存", settingsResult.message, settingsResult.status); + showNotice(t("设置保存"), settingsResult.message, settingsResult.status); return false; } } else { @@ -1113,7 +1549,7 @@ export function App() { if (result) { setRelay(result); await refreshRelayFiles(true); - if (!silent || !isSuccessStatus(result.status)) showNotice("纯 API 模式", result.message, result.status); + if (!silent || !isSuccessStatus(result.status)) showNotice(t("纯 API 模式"), result.message, result.status); } return !!result && isSuccessStatus(result.status) && result.configured; }; @@ -1123,7 +1559,7 @@ export function App() { if (result) { setRelay(result); await refreshRelayFiles(true); - if (!silent || !isSuccessStatus(result.status)) showNotice("官方登录模式", result.message, result.status); + if (!silent || !isSuccessStatus(result.status)) showNotice(t("官方登录模式"), result.message, result.status); } return !!result && isSuccessStatus(result.status) && !result.configured; }; @@ -1153,7 +1589,7 @@ export function App() { normalized = normalizeSettings(saveResult.settings); } setSettingsForm(normalized); - if (!isSuccessStatus(result.status)) showResultNotice("工具与插件", result); + if (!isSuccessStatus(result.status)) showResultNotice(t("工具与插件"), result); return normalized; }; @@ -1171,7 +1607,7 @@ export function App() { normalized = normalizeSettings(saveResult.settings); } setSettingsForm(normalized); - if (!isSuccessStatus(result.status)) showResultNotice("工具与插件", result); + if (!isSuccessStatus(result.status)) showResultNotice(t("工具与插件"), result); return normalized; }; @@ -1181,18 +1617,29 @@ export function App() { request: { configContents }, }), ); - if (result) showResultNotice("通用配置文件", result); + if (result) showResultNotice(t("通用配置文件"), result); return result && isSuccessStatus(result.status) ? result : null; }; const testRelayProfile = async (profile: RelayProfile) => { const result = await run(() => call("test_relay_profile", { profile })); - if (result) showNotice("供应商测试", result.message, result.status); + if (result) showNotice(t("供应商测试"), result.message, result.status); + }; + + const diagnoseRelayProfile = async (profile: RelayProfile) => { + const result = await run(() => call("diagnose_relay_profile", { profile })); + if (result) showNotice("Provider Doctor", result.message, result.status); + return result ?? null; + }; + + const testStepwiseSettings = async (settings: BackendSettings) => { + const result = await run(() => call("test_stepwise_settings", { settings })); + if (result) showNotice("Stepwise 测试", result.message, result.status); }; const fetchRelayProfileModels = async (profile: RelayProfile) => { const result = await run(() => call("fetch_relay_profile_models", { profile })); - if (result) showNotice("模型列表", result.message, result.status); + if (result) showNotice(t("模型列表"), result.message, result.status); return result && isSuccessStatus(result.status) ? result.models : null; }; @@ -1200,31 +1647,24 @@ export function App() { const switched = await clearRelayInjection(true); if (!switched) return; const result = await saveLaunchMode("relay", true); - if (result) showNotice("官方登录模式", "已切回官方登录;页面增强已设为兼容增强。", result.status); + if (result) showNotice(t("官方登录模式"), t("已切回官方登录;Codex增强已设为兼容增强。"), result.status); }; const switchPureApiMode = async () => { const switched = await applyPureApiInjection(true); if (!switched) return; const result = await saveLaunchMode("patch", true); - if (result) showNotice("纯 API 模式", "已切换到纯 API;页面增强已设为完整增强。", result.status); + if (result) showNotice(t("纯 API 模式"), t("已切换到纯 API;Codex增强已设为完整增强。"), result.status); }; const switchRelayProfile = async (next: BackendSettings, previousActiveRelayId = settingsForm.activeRelayId) => { - let switchSettings = normalizeSettings(next); - if (switchSettings.ccsLinkEnabled) { - const targetRelayId = switchSettings.activeRelayId; - const refreshed = await refreshSettings(true); - if (!refreshed) return; - const latest = normalizeSettings(refreshed); - if (!latest.relayProfiles.some((profile) => profile.id === targetRelayId)) { - showNotice("供应商切换", "目标供应商已不在 cc-switch 或本地配置中,请刷新供应商列表后重试。", "failed"); - return; - } - switchSettings = syncLegacyRelayFields({ ...latest, activeRelayId: targetRelayId }); + if (relaySwitching) { + showNotice(t("供应商切换中"), t("上一次切换还没有完成,请稍后再试。"), "failed"); + return; } + let switchSettings = normalizeSettings(next); if (!switchSettings.relayProfilesEnabled) { - showNotice("供应商配置已关闭", "当前不会写入 Codex config.toml / auth.json。打开供应商配置总开关后再切换。", "failed"); + showNotice(t("供应商配置已关闭"), t("当前不会写入 Codex config.toml / auth.json。打开供应商配置总开关后再切换。"), "failed"); return; } const targetBeforeSnapshot = activeRelayProfile(switchSettings); @@ -1233,18 +1673,8 @@ export function App() { targetRelayId: switchSettings.activeRelayId, targetRelayName: targetBeforeSnapshot.name, targetRelayMode: targetBeforeSnapshot.relayMode, - ccsLinkEnabled: switchSettings.ccsLinkEnabled, }); - const nextWithSnapshot = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId); - if (!nextWithSnapshot) { - logDiagnostic("switchRelayProfile.snapshot_failed", { - currentRelayId: settingsForm.activeRelayId, - targetRelayId: switchSettings.activeRelayId, - }); - return; - } - - const selectedBeforeSave = activeRelayProfile(nextWithSnapshot); + const selectedBeforeSave = activeRelayProfile(switchSettings); const validationError = relayProfileSwitchValidation(selectedBeforeSave); if (validationError) { logDiagnostic("switchRelayProfile.validation_failed", { @@ -1252,140 +1682,114 @@ export function App() { targetRelayName: selectedBeforeSave.name, error: validationError, }); - showNotice("供应商配置可能不正确", validationError, "failed"); - return; - } - - let selectedSettings = nextWithSnapshot; - logDiagnostic("switchRelayProfile.save_settings_start", { - targetRelayId: selectedBeforeSave.id, - targetRelayName: selectedBeforeSave.name, - }); - const settingsResult = await run(() => call("save_settings", { settings: nextWithSnapshot })); - if (settingsResult) { - selectedSettings = normalizeSettings(settingsResult.settings); - setSettings(settingsResult); - setSettingsForm(selectedSettings); - if (!isSuccessStatus(settingsResult.status)) { - logDiagnostic("switchRelayProfile.save_settings_failed", { - targetRelayId: selectedBeforeSave.id, - status: settingsResult.status, - message: settingsResult.message, - }); - showNotice("供应商切换", settingsResult.message, settingsResult.status); - return; - } - } else { - logDiagnostic("switchRelayProfile.save_settings_no_result", { - targetRelayId: selectedBeforeSave.id, - }); + showNotice(t("供应商配置可能不正确"), validationError, "failed"); return; } - - const selectedAfterSave = activeRelayProfile(selectedSettings); + switchSettings = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId); + const selectedAfterSave = activeRelayProfile(switchSettings); const command = relayProfileSwitchCommand(selectedAfterSave); + logDiagnostic("switchRelayProfile.apply_start", { targetRelayId: selectedAfterSave.id, targetRelayName: selectedAfterSave.name, + previousActiveRelayId, command, }); - const result = await run(() => call(command)); - if (!result) { - logDiagnostic("switchRelayProfile.apply_no_result", { - targetRelayId: selectedAfterSave.id, - command, + setRelaySwitching(true); + try { + const result = await run(() => + call("switch_relay_profile", { + request: { settings: switchSettings, previousActiveRelayId }, + }), + ); + if (!result) { + logDiagnostic("switchRelayProfile.apply_no_result", { + targetRelayId: selectedAfterSave.id, + }); + return; + } + const selectedSettings = normalizeSettings(result.settings); + setSettings({ + status: result.status, + message: result.message, + settings: selectedSettings, + settings_path: result.settingsPath, + user_scripts: result.user_scripts as UserScriptInventory, }); - return; - } - - setRelay(result); - await refreshRelayFiles(true); - if (!isSuccessStatus(result.status) || (selectedAfterSave.relayMode === "pureApi" && !result.configured)) { - logDiagnostic("switchRelayProfile.apply_failed", { - targetRelayId: selectedAfterSave.id, - command, + setSettingsForm(selectedSettings); + setRelay({ status: result.status, message: result.message, - configured: result.configured, + ...result.relay, }); - showNotice("供应商切换", relayProfileReadinessText(selectedAfterSave, result), result.status); - return; - } - - const currentSelected = activeRelayProfile(selectedSettings); - const launchMode = currentSelected.relayMode === "pureApi" ? "patch" : "relay"; - logDiagnostic("switchRelayProfile.launch_mode_start", { - targetRelayId: currentSelected.id, - launchMode, - }); - const modeResult = await saveLaunchMode(launchMode, true, selectedSettings); - if (modeResult) { + await refreshRelayFiles(true); + if (!isSuccessStatus(result.status)) { + logDiagnostic("switchRelayProfile.apply_failed", { + targetRelayId: selectedAfterSave.id, + status: result.status, + message: result.message, + activeRelayId: selectedSettings.activeRelayId, + }); + showNotice(t("供应商切换"), result.message, result.status); + return; + } + const currentSelected = activeRelayProfile(selectedSettings); logDiagnostic("switchRelayProfile.ok", { targetRelayId: currentSelected.id, - launchMode, - status: modeResult.status, - }); - showNotice("供应商切换", relayProfileModeSwitchedText(currentSelected), modeResult.status); - } else { - logDiagnostic("switchRelayProfile.launch_mode_no_result", { - targetRelayId: currentSelected.id, - launchMode, + launchMode: selectedSettings.launchMode, + status: result.status, }); + showNotice(t("供应商切换"), relayProfileModeSwitchedText(currentSelected), result.status); + } finally { + setRelaySwitching(false); } }; - const snapshotActiveRelayFilesBeforeSwitch = async (next: BackendSettings, previousActiveRelayId: string): Promise => { - const current = settingsForm.relayProfiles.find((profile) => profile.id === previousActiveRelayId) || activeRelayProfile(settingsForm); - const selected = activeRelayProfile(next); - if (current.id === selected.id) return next; - - logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.start", { - currentRelayId: current.id, - currentRelayName: current.name, - selectedRelayId: selected.id, - selectedRelayName: selected.name, - }); + const snapshotActiveRelayFilesBeforeSwitch = async ( + next: BackendSettings, + previousActiveRelayId: string, + ): Promise => { + const profileId = previousActiveRelayId.trim(); + if (!profileId) return next; const result = await run(() => call("backfill_relay_profile_from_live", { - request: { settings: next, profileId: current.id }, + request: { settings: next, profileId }, }), ); - if (!result || !isSuccessStatus(result.status)) { - logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.failed", { - currentRelayId: current.id, - selectedRelayId: selected.id, - status: result?.status, - message: result?.message, - }); - showNotice("供应商切换", result?.message ?? "读取当前配置文件失败,已停止切换以避免覆盖用户改动。", result?.status ?? "failed"); - return null; + if (!result) return next; + const normalized = normalizeSettings(result.settings); + if (!isSuccessStatus(result.status)) { + showNotice(t("供应商切换"), result.message, result.status); + return next; } - - logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.ok", { - currentRelayId: current.id, - selectedRelayId: selected.id, - }); - return syncLegacyRelayFields(normalizeSettings(result.settings)); + return normalized; }; - const copyText = async (text: string, message: string) => { try { await navigator.clipboard.writeText(text); } catch (error) { - showNotice("复制失败", stringifyError(error), "failed"); + showNotice(t("复制失败"), stringifyError(error), "failed"); } }; const openExternalUrl = async (url: string) => { const result = await run(() => call>>("open_external_url", { url })); if (result) { - showResultNotice("打开链接", result, { silentSuccess: true }); + showResultNotice(t("打开链接"), result, { silentSuccess: true }); } }; const showNotice = (title: string, message: string, status?: Status) => { - setNotice({ title, message, status }); + setNotice({ title, message: t(message), status }); + }; + + const exitManagerApp = async () => { + await call("manager_exit_app"); + }; + + const hideManagerToTray = async () => { + await call("manager_hide_to_tray"); }; const showResultNotice = ( @@ -1409,10 +1813,30 @@ export function App() { await refreshOverview(true); await refreshSettings(true); await refreshRelay(true); + await refreshEnvConflicts(true); await refreshProviderSyncTargets(true); + await refreshPendingProviderImport(true); + await refreshRemotePluginMarketplace(true); })(); }, []); + useEffect(() => { + if (getLanguage() === "en") { + void invoke("update_tray_labels", { + showLabel: "Show window", + quitLabel: "Quit", + windowTitle: "Codex++ Manager", + }); + } + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + void refreshPendingProviderImport(true); + }, 1200); + return () => window.clearInterval(timer); + }, []); + useEffect(() => { document.documentElement.classList.toggle("dark", theme === "dark"); document.documentElement.classList.toggle("light", theme === "light"); @@ -1437,7 +1861,9 @@ export function App() { refreshCurrent: () => navigate(route), launch, restart, - repairBackend, + repairPluginMarketplace, + refreshRemotePluginMarketplace, + repairRemotePluginMarketplace, installEntrypoints, uninstallEntrypoints, repairShortcuts, @@ -1447,30 +1873,31 @@ export function App() { saveSettingsValue, refreshSettings, resetSettings, + resetImageOverlaySettings, chooseCodexAppPath: async (mode: "folder" | "file") => { let selected: unknown; try { selected = await open( mode === "folder" - ? { directory: true, multiple: false, title: "选择 Codex 应用目录" } + ? { directory: true, multiple: false, title: t("选择 Codex 应用目录") } : { directory: false, multiple: false, - title: "选择 Codex.exe 或 Codex.app", - filters: [{ name: "Codex 应用", extensions: ["exe", "app"] }], + title: t("选择 Codex.exe 或 Codex.app"), + filters: [{ name: t("Codex 应用"), extensions: ["exe", "app"] }], }, ); } catch (error) { // Surface plugin failures (e.g. missing capability permission) so the // buttons no longer appear unresponsive — see #345. const message = error instanceof Error ? error.message : String(error); - showNotice("Codex 应用路径", `打开选择器失败:${message}`, "failed"); + showNotice(t("Codex 应用路径"), tf("打开选择器失败:{0}", [message]), "failed"); return; } if (typeof selected === "string" && selected.trim()) { const result = await saveCodexAppPath(selected.trim()); if (result) { - showNotice("Codex 应用路径", "应用路径已保存,之后启动会自动复用。", result.status); + showNotice(t("Codex 应用路径"), t("应用路径已保存,之后启动会自动复用。"), result.status); } } }, @@ -1481,19 +1908,41 @@ export function App() { setSettings(result); setSettingsForm(normalizeSettings(result.settings)); setLaunchForm((current) => ({ ...current, appPath: "" })); - showNotice("Codex 应用路径", "已清除保存路径,后续启动会回到自动探测。", result.status); + showNotice(t("Codex 应用路径"), t("已清除保存路径,后续启动会回到自动探测。"), result.status); await refreshOverview(true); } }, + chooseImageOverlayPath: async () => { + let selected: unknown; + try { + selected = await open({ + directory: false, + multiple: false, + title: t("选择覆盖图片"), + filters: [{ name: t("图片"), extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }], + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showNotice(t("图片覆盖层"), tf("打开选择器失败:{0}", [message]), "failed"); + return; + } + if (typeof selected === "string" && selected.trim()) { + setSettingsForm((current) => ({ + ...current, + codexAppImageOverlayEnabled: true, + codexAppImageOverlayPath: selected.trim(), + })); + } + }, saveManualCodexAppPath: async () => { const appPath = launchForm.appPath.trim(); if (!appPath) { - showNotice("Codex 应用路径", "请先填写或选择应用路径。", "failed"); + showNotice(t("Codex 应用路径"), t("请先填写或选择应用路径。"), "failed"); return; } const result = await saveCodexAppPath(appPath); if (result) { - showNotice("Codex 应用路径", "应用路径已保存,之后启动会自动复用。", result.status); + showNotice(t("Codex 应用路径"), t("应用路径已保存,之后启动会自动复用。"), result.status); } }, syncProvidersNow, @@ -1507,9 +1956,12 @@ export function App() { }, refreshRelay, refreshRelayFiles, + refreshEnvConflicts, + removeEnvConflicts, + refreshCcsProviders, + importCcsProviders, refreshLiveContextEntries, syncLiveContextEntries, - importCcsProviders, refreshAds, refreshScriptMarket, installMarketScript, @@ -1517,6 +1969,7 @@ export function App() { deleteUserScript, refreshLocalSessions, deleteLocalSession, + deleteLocalSessions, refreshZedRemoteProjects, openZedRemoteProject, forgetZedRemoteProject, @@ -1529,21 +1982,24 @@ export function App() { deleteContextEntry, extractRelayCommonConfig, testRelayProfile, + diagnoseRelayProfile, + testStepwiseSettings, fetchRelayProfileModels, switchRelayProfile, + relaySwitching, switchOfficialMode, switchPureApiMode, refreshLogs, refreshDiagnostics, showMessage: async (title: string, message: string, status?: Status) => showNotice(title, message, status), - copyLogs: () => copyText(logs?.text ?? "", "日志已复制。"), - copyDiagnostics: () => copyText(diagnostics?.report ?? "", "诊断报告已复制。"), + copyLogs: () => copyText(logs?.text ?? "", t("日志已复制。")), + copyDiagnostics: () => copyText(diagnostics?.report ?? "", t("诊断报告已复制。")), goLogs: () => navigate("about"), checkHealth: async () => { await refreshOverview(true); await refreshRelay(true); await refreshWatcher(true); - showNotice("检查完成", "已刷新 Codex 应用、入口和 Watcher 状态。", "ok"); + showNotice(t("检查完成"), t("已刷新 Codex 应用、入口和 Watcher 状态。"), "ok"); }, installWatcher: () => watcherAction("install_watcher"), uninstallWatcher: () => watcherAction("uninstall_watcher"), @@ -1551,7 +2007,7 @@ export function App() { disableWatcher: () => watcherAction("disable_watcher"), toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark")), }), - [route, launchForm, settingsForm, settings, removeOwnedData, update, logs, diagnostics, theme, relayFiles, localSessions, zedRemoteProjects, selectedProviderSyncTarget], + [route, launchForm, settingsForm, settings, removeOwnedData, update, updateInstallProgress.active, logs, diagnostics, theme, relayFiles, localSessions, zedRemoteProjects, selectedProviderSyncTarget, envConflicts, ccsProviders], ); const hasUpdate = update?.updateAvailable === true; @@ -1570,14 +2026,14 @@ export function App() { setRoute("about"); void checkUpdate(false); }} - title={`发现新版本 ${update?.latestVersion ?? ""}`} + title={tf("发现新版本 {0}", [update?.latestVersion ?? ""])} type="button" >