mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-04 12:28:37 +08:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
649c45e4bd | ||
|
|
d7610f9b12 | ||
|
|
e4822237fb | ||
|
|
5d18b2555e | ||
|
|
2ace729801 | ||
|
|
488f27e095 | ||
|
|
826a142fc0 | ||
|
|
d1b40285dd | ||
|
|
3ac28c3193 | ||
|
|
39dc8fc535 | ||
|
|
762ff36131 | ||
|
|
5894dceb98 | ||
|
|
92c371e5e0 | ||
|
|
936f2f49e4 | ||
|
|
9687a4b11c | ||
|
|
32bab7731e | ||
|
|
f64848ca9c | ||
|
|
94ab211431 | ||
|
|
e8b0e5b73c | ||
|
|
5b6655754d | ||
|
|
1fd5b1e608 | ||
|
|
7d0e0064d5 | ||
|
|
d38548a1f4 | ||
|
|
f25e43d281 | ||
|
|
d404905812 | ||
|
|
c200e6b86e |
@@ -131,9 +131,14 @@ jobs:
|
||||
--draft
|
||||
fi
|
||||
|
||||
release_id=$(gh api "/repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" \
|
||||
| head -n 1)
|
||||
release_id=""
|
||||
for attempt in {1..10}; do
|
||||
release_id=$(gh release view "$RELEASE_TAG" \
|
||||
--json databaseId \
|
||||
--jq '.databaseId' 2>/dev/null || true)
|
||||
[ -z "$release_id" ] || break
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "Unable to locate draft release for ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
@@ -158,12 +163,12 @@ jobs:
|
||||
platform: macos-latest
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-arm64[ext]
|
||||
asset_pattern: Codex-X-[version]-macos-apple-silicon[ext]
|
||||
- name: macOS Intel
|
||||
platform: macos-15-intel
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-x64[ext]
|
||||
asset_pattern: Codex-X-[version]-macos-intel[ext]
|
||||
- name: Linux x64
|
||||
platform: ubuntu-22.04
|
||||
macos: false
|
||||
@@ -218,7 +223,7 @@ jobs:
|
||||
|
||||
- name: Test Windows filesystem compatibility
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
|
||||
- name: Import Apple signing certificate and notarization key
|
||||
if: matrix.macos
|
||||
@@ -382,6 +387,28 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Match release asset labels to filenames
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets_endpoint="/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100"
|
||||
gh api "$assets_endpoint" \
|
||||
--jq '.[] | select(.label != .name) | [.id, .name] | @tsv' \
|
||||
| while IFS=$'\t' read -r asset_id asset_name; do
|
||||
gh api --method PATCH \
|
||||
"/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
-f "label=${asset_name}" >/dev/null
|
||||
done
|
||||
|
||||
mismatched_labels=$(gh api "$assets_endpoint" \
|
||||
--jq '[.[] | select(.label != .name)] | length')
|
||||
if [ "$mismatched_labels" -ne 0 ]; then
|
||||
echo "Release asset labels do not match their filenames" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download and validate updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -404,9 +431,24 @@ jobs:
|
||||
--manifest latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION" \
|
||||
--rewrite-download-urls
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--rewrite-download-urls \
|
||||
--require-signature-assets
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
- name: Remove public signature assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
--jq '.[] | select(.name | endswith(".sig")) | .id' \
|
||||
| while read -r asset_id; do
|
||||
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
|
||||
done
|
||||
|
||||
- name: Publish the verified release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -429,7 +471,9 @@ jobs:
|
||||
python3 scripts/validate_updater_release.py \
|
||||
--manifest published-latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION"
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG"
|
||||
|
||||
python3 - <<'PY' > updater-urls.txt
|
||||
import json
|
||||
@@ -438,9 +482,11 @@ jobs:
|
||||
print(entry["url"])
|
||||
PY
|
||||
sort -u updater-urls.txt | while read -r url; do
|
||||
curl -fsSLI \
|
||||
curl -fsSL \
|
||||
--range 0-0 \
|
||||
--retry 5 \
|
||||
--retry-delay 3 \
|
||||
--retry-all-errors \
|
||||
"$url" >/dev/null
|
||||
"$url" \
|
||||
-o /dev/null
|
||||
done
|
||||
|
||||
+170
@@ -2,6 +2,176 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [v0.3.16] - 2026-09-09
|
||||
|
||||
### 新增与调整
|
||||
|
||||
- 官方登录与第三方供应商统一为一键复制:副本立即加入列表,不进入编辑页,不弹出二次确认;自动添加不重复的副本名称。
|
||||
- 官方与第三方配置的 TOML 编辑区新增“开启 1M 上下文窗口”复选框,保留自定义压缩阈值及其他配置。
|
||||
- 设置新增“通用设置 / 用量统计”标签页,并复用统一的淡入淡出动画;用量统计展示本地 Codex Token 总量、输入与输出、缓存命中率、每日趋势、模型分布和最近 10 个会话,支持时间范围、模型筛选与手动刷新。最近会话显示聊天标题,无标题时使用项目名。
|
||||
- 用量统计按主会话归并子代理及其后代的实际用量,子代理不再单独列入最近会话或增加活跃会话数;用户主动分叉的新聊天保持独立。
|
||||
- 支持添加、复制和重命名多个官方 Codex 登录配置,可与第三方 API 供应商一起管理和切换;各配置独立保存认证与完整 TOML,切换前保留当前账号最新登录状态。
|
||||
- 指令提示词页面顶部标题的强调色统一为蓝色,适配浅色与深色主题。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 GitHub 模板同步期间查看、编辑或启用提示词可能拖住供应商操作的问题;模板内容优先读取本地修改、缓存和内置版本,模板详情加载不再占用其他页面的操作状态。
|
||||
- 修复首次识别到的 CC Switch 当前供应商在切换后从列表消失的问题;切换前保存完整配置与认证信息,并处理同名记录、重复配置和失败回滚。
|
||||
|
||||
## [v0.3.15] - 2026-08-20
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Windows 重启 Codex 时仅隐藏到托盘、后台未退出,以及再次重启报错的问题。
|
||||
|
||||
## [v0.3.14] - 2026-08-20
|
||||
|
||||
### 更新
|
||||
|
||||
- MCP 和 Skills 支持添加自定义备注。
|
||||
- 供应商支持复制,同一 API 平台可保存多个模型或相同配置,复制项会添加到列表末尾。
|
||||
- 供应商列表不再显示重复的模型信息。
|
||||
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复相同供应商配置被覆盖,以及复制当前供应商后启用状态丢失的问题。
|
||||
- 修复子代理会话被误判为“待同步”的问题。
|
||||
|
||||
## [v0.3.13] - 2026-08-18
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复归档会话被错误标记为“待同步”并参与供应商同步的问题;归档会话现在会保持归档状态,不再作为同步候选。
|
||||
|
||||
## [v0.3.12] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.11] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.10] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.9] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化了软件的流畅度和部分 bug。
|
||||
|
||||
## [v0.3.8] - 2026-08-03
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复了某些问题。
|
||||
|
||||
## [v0.3.7] - 2026-08-02
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复了一些 bug。
|
||||
|
||||
## [v0.3.6] - 2026-07-31
|
||||
|
||||
### 更新
|
||||
|
||||
- 供应商与 OpenAI Official 切换改为热更新;切换后新建会话即可生效,不再要求重启 Codex 客户端。
|
||||
- macOS 关闭主窗口后进入顶部栏驻留模式,Windows 隐藏任务栏窗口;托盘菜单仍可恢复窗口或明确退出应用。
|
||||
- 新增独立的项目维护日志,记录配置写入、供应商切换、会话同步和桌面生命周期的长期约束与验证依据。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复官方认证被第三方供应商覆盖或清除的问题;第三方密钥仅写入当前 provider,官方 ChatGPT 登录与官方 API Key 均使用独立快照保存并可往返恢复。
|
||||
- 修复编辑活动供应商会新建副本、重复导入 cc-switch 供应商产生多条相同记录,以及旧版 cc-switch 数据库缺少 `category` 字段时无法读取官方认证的问题。
|
||||
- 修复供应商、提示词及其他配置操作并发写入时可能覆盖或破坏 `config.toml` 的问题;写入现在使用跨进程锁、原始快照、条件原子替换和失败回滚。
|
||||
- 修复会话同步显示成功但 Codex 未实际使用同步结果的问题;只处理活动 SQLite,严格校验 SQLite 指定的 rollout 路径、文件身份和线程 ID,并兼容 `codex-dev.db` 与显式 `sqlite_home` 下的自定义数据库名。
|
||||
- 修复会话列表将旧数据库或孤立 JSONL 计入活动会话、把 JSONL 记录行数误当会话数,以及同步过程中修改非活动数据库或工作目录字段的问题。
|
||||
|
||||
## [v0.3.5] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
|
||||
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
|
||||
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
|
||||
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
|
||||
|
||||
## [v0.3.4] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
|
||||
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
|
||||
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
|
||||
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
|
||||
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
|
||||
|
||||
## [v0.3.3] - 2026-07-26
|
||||
|
||||
### 调整
|
||||
|
||||
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
|
||||
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
|
||||
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
|
||||
|
||||
## [v0.3.2] - 2026-07-25
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
|
||||
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
|
||||
|
||||
### 安全
|
||||
|
||||
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
|
||||
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
|
||||
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
|
||||
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
|
||||
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
|
||||
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
|
||||
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
|
||||
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
|
||||
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
|
||||
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
|
||||
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
|
||||
|
||||
### 安全
|
||||
|
||||
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
|
||||
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
|
||||
|
||||
### 开发
|
||||
|
||||
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
|
||||
|
||||
## [v0.3.0] - 2026-07-16
|
||||
|
||||
### 更新
|
||||
|
||||
+43
-7
@@ -37,9 +37,10 @@ When you use Codex Desktop, the CLI, third-party APIs, and multiple prompts toge
|
||||
You can use it to:
|
||||
|
||||
- Choose from the prompt library or import your own `.md`, then keep the existing prompt or replace it
|
||||
- Save, test, and switch between OpenAI Official and third-party APIs, including Providers imported from cc-switch
|
||||
- Manage multiple named official Codex login profiles and third-party APIs, duplicate or switch them with a click, and import Providers from cc-switch
|
||||
- Search and organize local sessions by project, check / automatically repair problems, and precisely delete sessions you no longer need
|
||||
- Manage Skills and MCP in one place, and inspect the current `config.toml`, `auth.json`, and operation backups
|
||||
- Explore token usage by date and model in Settings, with subagent usage attributed to its main conversation
|
||||
|
||||
## Preview
|
||||
|
||||
@@ -117,7 +118,7 @@ You can use it to:
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">⚡ <b>Switch APIs / relays</b></td>
|
||||
<td align="left">Save, test, and enable multiple third-party Providers, or import them from cc-switch; entries with the same endpoint and Key are merged automatically. Create or reopen a session after switching to apply the change without restarting the entire Codex client.</td>
|
||||
<td align="left">Save multiple named official Codex login profiles alongside third-party Providers, with one-click duplication and switching. Third-party Providers support connection checks, model discovery / testing, and imports from cc-switch.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">💬 <b>Organize local sessions</b></td>
|
||||
@@ -129,7 +130,11 @@ You can use it to:
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📝 <b>Manage config and login</b></td>
|
||||
<td align="left">Inspect the active Codex <code>config.toml</code> and <code>auth.json</code> in one place, with a clear distinction between official login credentials and third-party API Keys. Important writes are backed up automatically and can be restored when needed.</td>
|
||||
<td align="left">Inspect <code>config.toml</code> and <code>auth.json</code> in one place. Both official and third-party profile editors offer an “Enable 1M context window” checkbox beside <code>config.toml>; it applies on save and requires model support. Important writes are backed up automatically.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📊 <b>Track token usage</b></td>
|
||||
<td align="left">Switch between General and Usage statistics in Settings. Filter local usage by date and model, explore daily trends, cache hit rate, and model distribution, and view the 10 most recent main conversations. Subagent usage is attributed to its main conversation and does not count as a separate session.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 <b>Use it across platforms</b></td>
|
||||
@@ -143,7 +148,7 @@ You can use it to:
|
||||
### 1. Prompt Template Center
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Template_library-5_total-2563eb?style=flat-square" alt="5 templates in the current library" />
|
||||
<img src="https://img.shields.io/badge/Template_library-11_total-2563eb?style=flat-square" alt="11 templates in the current library" />
|
||||
<img src="https://img.shields.io/badge/Bundled_offline-5_templates-16a34a?style=flat-square" alt="5 templates bundled for offline use" />
|
||||
<img src="https://img.shields.io/badge/GitHub_sync-Automatic_updates-f59e0b?style=flat-square" alt="Automatic GitHub template sync" />
|
||||
</p>
|
||||
@@ -151,7 +156,7 @@ You can use it to:
|
||||
> [!TIP]
|
||||
> **Ready after installation, automatically expanded when online.**
|
||||
>
|
||||
> The app bundle includes all 5 current templates for offline use. After launch, it synchronizes updates and newly added templates from GitHub `examples/` in the background, without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
|
||||
> The app bundle includes 5 templates for offline use. After launch, it synchronizes 6 additional software-development and writing templates, plus later updates, from GitHub `examples/` in the background without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
@@ -185,6 +190,36 @@ You can use it to:
|
||||
<td align="left">A Chinese technical-operator persona with routing for coding, CTF, reverse engineering, memory, and protocol work</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">Long-term maintenance with reuse, minimal changes, safety, testing, and maintainability</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">Evidence-driven debugging from reliable reproduction to root-cause repair and regression checks</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">Severity-ranked review of defects, regressions, security risks, and test gaps</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">Chinese and English editing that preserves the author's meaning and facts</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">Source-grounded README, guide, API, architecture, and release documentation</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">Turns scattered material into a structured report, proposal, retrospective, or article draft</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -208,7 +243,8 @@ A backup is created automatically before every enable or disable action. In addi
|
||||
> [!NOTE]
|
||||
> After enabling a new third-party Provider, create or reopen a Codex session to use the new relay. You do not need to restart the entire Codex client.
|
||||
|
||||
- Save multiple third-party Providers and always see which one is currently active
|
||||
- Save multiple named official Codex login profiles and third-party Providers, and always see which one is currently active
|
||||
- Duplicate a profile immediately with one click, without a confirmation dialog or opening the editor; rename it whenever needed
|
||||
- Test an API endpoint before switching, and save or enable a configuration separately
|
||||
- Edit the Base URL, API Key, Model, Wire API, and complete TOML on the same page
|
||||
- cc-switch imports report added, updated, merged, and skipped entries; the same URL + Key is no longer shown more than once
|
||||
@@ -369,7 +405,7 @@ Build desktop bundles:
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS Installation Note
|
||||
## Desktop Installation Notes
|
||||
|
||||
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
|
||||
|
||||
|
||||
@@ -38,9 +38,10 @@
|
||||
|
||||
- 像管理插件一样管理提示词:分类、导入 Markdown、自定义编辑、一键启用 / 禁用
|
||||
- 内置 5 套提示词模板,同时支持用户把自己的提示词变成可视化模板库
|
||||
- 保存、测试并切换 OpenAI Official 与第三方 API,还能从 cc-switch 导入现有供应商
|
||||
- 管理多个可命名的官方 Codex 登录与第三方 API,一键复制、切换,并从 cc-switch 导入现有供应商
|
||||
- 同步、检查、搜索和删除本地会话,按项目路径整理 Codex 历史记录
|
||||
- 集中管理 Skills 与 MCP,查看当前 `config.toml`、`auth.json` 和操作备份
|
||||
- 在设置中按日期、模型查看 Token 用量趋势,子代理用量归入所属主会话
|
||||
|
||||
## 软件预览
|
||||
|
||||
@@ -97,7 +98,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Provider / API</b></td>
|
||||
<td align="left">添加、编辑、启用、删除第三方供应商;支持连接检测、模型获取 / 测试、从 cc-switch 导入,并可在 OpenAI Official 与中转 API 之间切换。</td>
|
||||
<td align="left">保存多个可命名的官方 Codex 登录配置,与第三方供应商统一管理、一键复制和切换;第三方供应商支持连接检测、模型获取 / 测试及从 cc-switch 导入。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>会话管理</b></td>
|
||||
@@ -109,7 +110,11 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>配置与登录</b></td>
|
||||
<td align="left">集中查看 Codex 当前使用的 <code>config.toml</code> 与 <code>auth.json</code>,区分官方登录态和第三方 API Key;重要写入前自动备份。</td>
|
||||
<td align="left">集中查看 <code>config.toml</code> 与 <code>auth.json</code>;官方登录和第三方供应商编辑页均可在 <code>config.toml</code> 旁勾选“开启 1M 上下文窗口”,保存后生效,需模型支持;重要写入前自动备份。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>用量统计</b></td>
|
||||
<td align="left">设置页提供“通用设置 / 用量统计”标签,按日期与模型筛选本地 Token 用量,查看每日趋势、缓存命中率、模型分布及最近 10 个主会话;子代理用量归入所属主会话,不单独计为会话。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
@@ -123,7 +128,7 @@
|
||||
### 1. 可视化提示词注入中心
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/当前模板库-5_套-2563eb?style=flat-square" alt="当前模板库 5 套" />
|
||||
<img src="https://img.shields.io/badge/当前模板库-11_套-2563eb?style=flat-square" alt="当前模板库 11 套" />
|
||||
<img src="https://img.shields.io/badge/离线内置-5_套-16a34a?style=flat-square" alt="离线内置 5 套" />
|
||||
<img src="https://img.shields.io/badge/GitHub_同步-自动更新-f59e0b?style=flat-square" alt="GitHub 自动同步" />
|
||||
<img src="https://img.shields.io/badge/自定义提示词-支持导入_编辑-7c3aed?style=flat-square" alt="支持自定义提示词" />
|
||||
@@ -132,7 +137,7 @@
|
||||
> [!TIP]
|
||||
> **安装后就能用,联网后自动补齐,也能维护自己的提示词库。**
|
||||
>
|
||||
> 安装包离线自带当前全部 5 套模板;软件启动后可同步 GitHub `examples/` 的更新和新增模板。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
|
||||
> 安装包离线自带 5 套模板;软件启动后会从 GitHub `examples/` 同步另外 6 套软件开发与写作辅助模板,以及后续更新。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
|
||||
|
||||
Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可视化提示词注入与管理工具:
|
||||
|
||||
@@ -174,6 +179,36 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
<td align="left">中文技术操作员人格,覆盖 coding、CTF、逆向、内存与协议任务路由</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">长期维护正式项目,强调复用、最小改动、安全、测试与可维护性</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">从稳定复现和证据采集推进到根因修复与回归验证</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">按严重级别审查缺陷、回归、安全风险和测试缺口</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">在保留原意和事实的前提下润色中英文表达</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">基于代码与事实编写 README、指南、API 和发布文档</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">将零散材料组织成报告、方案、复盘或文章初稿</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +235,8 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
> [!NOTE]
|
||||
> 启用新的第三方供应商后,新建或重新打开 Codex 会话即可使用新的中转,不需要重启整个 Codex 客户端。
|
||||
|
||||
- 保存多个第三方供应商,随时查看当前正在使用哪一个
|
||||
- 保存多个可命名的官方 Codex 登录配置与第三方供应商,随时查看当前正在使用哪一个
|
||||
- 点击“复制”直接新增独立副本,无需确认或进入编辑页;名称可稍后修改
|
||||
- 切换前可检测连接,并可获取模型进行测试
|
||||
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
|
||||
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
|
||||
@@ -358,7 +394,7 @@ pnpm dev
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS 安装说明
|
||||
## 桌面端安装说明
|
||||
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.16",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+412
-1
@@ -68,6 +68,137 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -163,6 +294,19 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
@@ -346,7 +490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.0"
|
||||
version = "0.3.16"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -361,6 +505,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"thiserror 2.0.18",
|
||||
"toml_edit 0.22.27",
|
||||
@@ -378,6 +523,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -790,6 +944,33 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -817,6 +998,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -961,6 +1162,19 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
@@ -1333,6 +1547,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -2245,6 +2465,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
@@ -2284,6 +2514,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2372,6 +2608,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -2417,6 +2664,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3093,6 +3354,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -3526,6 +3797,22 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
@@ -4004,9 +4291,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -4056,6 +4355,17 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -5107,6 +5417,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.3",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
@@ -5233,3 +5604,43 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.118",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.0"
|
||||
version = "0.3.16"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -15,7 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.8.2", features = [] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -37,3 +37,4 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-single-instance = "=2.4.3"
|
||||
|
||||
@@ -3,7 +3,110 @@ use crate::file_io::ensure_directory;
|
||||
use crate::paths::app_home;
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 4;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl DatabaseInitializer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
migration_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_at(&self, path: &Path) -> Result<Connection> {
|
||||
self.open_at_with(path, initialize_schema)
|
||||
}
|
||||
|
||||
fn open_at_with(
|
||||
&self,
|
||||
path: &Path,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<Connection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
if schema_is_current(&conn)? {
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
// Serialize first-time migrations inside this process. The persistent
|
||||
// schema version also prevents repeated migrations across launches and
|
||||
// detects a database replaced at the same path.
|
||||
let _migration_guard = self
|
||||
.migration_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !schema_is_current(&conn)? {
|
||||
migrate_schema(&conn, initialize)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_version(conn: &Connection) -> Result<i64> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn schema_is_current(conn: &Connection) -> Result<bool> {
|
||||
Ok(schema_version(conn)? >= APP_DB_SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
fn migrate_schema(
|
||||
conn: &Connection,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
|
||||
let migration = (|| {
|
||||
// Another Codex-X process may have completed the migration while this
|
||||
// connection waited for SQLite's write lock.
|
||||
if !schema_is_current(conn)? {
|
||||
initialize(conn)?;
|
||||
conn.pragma_update(None, "user_version", APP_DB_SCHEMA_VERSION)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
conn.execute_batch("COMMIT")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})();
|
||||
|
||||
match migration {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => match conn.execute_batch("ROLLBACK") {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Database(format!(
|
||||
"{error}; app database migration rollback failed: {rollback_error}"
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn database_initializer() -> &'static DatabaseInitializer {
|
||||
static INITIALIZER: OnceLock<DatabaseInitializer> = OnceLock::new();
|
||||
INITIALIZER.get_or_init(DatabaseInitializer::new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_db_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static TEST_DB_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
TEST_DB_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("test app database lock poisoned")
|
||||
}
|
||||
|
||||
fn db_path() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("codexx.db"))
|
||||
@@ -34,12 +137,7 @@ fn ensure_sqlite_column(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(&path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -50,6 +148,8 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -71,6 +171,12 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -87,15 +193,59 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skills_mcp_notes (
|
||||
codex_dir TEXT NOT NULL,
|
||||
item_kind TEXT NOT NULL CHECK(item_kind IN ('skill', 'mcp')),
|
||||
item_id TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(codex_dir, item_kind, item_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profiles (
|
||||
codex_dir TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(codex_dir, id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profile_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
conn,
|
||||
"providers",
|
||||
"toml_config",
|
||||
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"source",
|
||||
"ALTER TABLE providers ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"source_id",
|
||||
"ALTER TABLE providers ADD COLUMN source_id TEXT",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
|
||||
ON providers(source, source_id)
|
||||
WHERE source_id IS NOT NULL;",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
@@ -131,5 +281,252 @@ pub(crate) fn open() -> Result<Connection> {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(conn)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
database_initializer().open_at(&db_path()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_db_path(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir()
|
||||
.join(format!(
|
||||
"codex-x-app-db-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
))
|
||||
.join("codexx.db")
|
||||
}
|
||||
|
||||
fn remove_test_db(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::remove_dir_all(parent).expect("remove app database test directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_opens_do_not_rerun_legacy_cleanup() {
|
||||
let path = test_db_path("cleanup-once");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
conn.execute_batch(
|
||||
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
|
||||
VALUES
|
||||
('kept', 'Kept', 'same.md', 'same', '1', '1'),
|
||||
('external-duplicate', 'Duplicate', 'same.md', 'same', '2', '2');",
|
||||
)
|
||||
.expect("seed a post-migration duplicate");
|
||||
drop(conn);
|
||||
|
||||
let reopened_initializer = DatabaseInitializer::new();
|
||||
let conn = reopened_initializer
|
||||
.open_at(&path)
|
||||
.expect("reopen database in a new process lifecycle");
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||||
.expect("count prompts after reopen");
|
||||
assert_eq!(count, 2, "reopen must not rerun migration cleanup");
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_replaced_at_the_same_path_is_initialized_again() {
|
||||
let path = test_db_path("replace-database");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&path).expect("replace initialized database");
|
||||
let conn = initializer
|
||||
.open_at(&path)
|
||||
.expect("initialize replacement database");
|
||||
let provider_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("query replacement database schema");
|
||||
assert_eq!(provider_count, 0);
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read replacement version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
|
||||
let path = test_db_path("skills-mcp-notes-migration");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create legacy database directory");
|
||||
}
|
||||
let legacy = Connection::open(&path).expect("create legacy database");
|
||||
legacy
|
||||
.execute_batch(
|
||||
"CREATE TABLE managed_skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO managed_skills
|
||||
(id, name, directory, enabled, updated_at)
|
||||
VALUES ('legacy-skill', 'Legacy skill', 'legacy-skill', 1, '2026-01-01T00:00:00Z');
|
||||
PRAGMA user_version = 1;",
|
||||
)
|
||||
.expect("seed version one database");
|
||||
drop(legacy);
|
||||
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let migrated = initializer.open_at(&path).expect("migrate database");
|
||||
assert_eq!(
|
||||
schema_version(&migrated).expect("read migrated schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
let skill_count: i64 = migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM managed_skills WHERE id = 'legacy-skill'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count preserved skill");
|
||||
assert_eq!(skill_count, 1);
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO skills_mcp_notes
|
||||
(codex_dir, item_kind, item_id, note, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'skill', 'legacy-skill', 'my note', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write note after migration");
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO active_provider_selections
|
||||
(codex_dir, provider_id, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'legacy-provider', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write active provider selection after migration");
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_first_opens_initialize_once() {
|
||||
let path = test_db_path("concurrent-init");
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let mut workers = Vec::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let path = path.clone();
|
||||
// Separate initializers model independent Codex-X processes; the
|
||||
// SQLite transaction and persistent version still permit one run.
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = Arc::clone(&attempts);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
workers.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
initializer.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
barrier.wait();
|
||||
for worker in workers {
|
||||
drop(
|
||||
worker
|
||||
.join()
|
||||
.expect("join concurrent database opener")
|
||||
.expect("open database concurrently"),
|
||||
);
|
||||
}
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_initialization_can_retry() {
|
||||
let path = test_db_path("retry-init");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = AtomicUsize::new(0);
|
||||
|
||||
let error = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Err(CodexxError::Database(
|
||||
"injected transient initialization failure".to_string(),
|
||||
))
|
||||
})
|
||||
.expect_err("first initialization must fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("transient initialization failure"));
|
||||
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
.expect("retry initialization");
|
||||
drop(conn);
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.expect("open initialized database");
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialized_read_connection_does_not_need_a_write_lock() {
|
||||
let path = test_db_path("read-with-writer");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let writer = initializer.open_at(&path).expect("initialize database");
|
||||
writer
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.expect("hold database write reservation");
|
||||
|
||||
let reader = initializer
|
||||
.open_at(&path)
|
||||
.expect("open reader while write reservation is held");
|
||||
let count: i64 = reader
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("read while another connection holds write reservation");
|
||||
assert_eq!(count, 0);
|
||||
|
||||
drop(reader);
|
||||
writer
|
||||
.execute_batch("ROLLBACK")
|
||||
.expect("release write lock");
|
||||
drop(writer);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::constants::AGENTS_FILENAME;
|
||||
use crate::error::Result;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::prompts::agents_path;
|
||||
@@ -7,7 +7,7 @@ use crate::{auth_path, config_path};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -40,6 +40,84 @@ pub(crate) struct BackupEntry {
|
||||
had_agents: bool,
|
||||
}
|
||||
|
||||
fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in absolute.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
Component::RootDir => normalized.push(component.as_os_str()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Resolves every existing ancestor while retaining a normalized suffix. This
|
||||
/// compares missing CODEX_HOME targets without requiring the target to exist.
|
||||
fn normalized_path_identity(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
for ancestor in absolute.ancestors() {
|
||||
match fs::canonicalize(ancestor) {
|
||||
Ok(canonical) => {
|
||||
let suffix = absolute
|
||||
.strip_prefix(ancestor)
|
||||
.expect("ancestor must be a path prefix");
|
||||
return lexical_absolute_path(&canonical.join(suffix));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_err(ancestor, error)),
|
||||
}
|
||||
}
|
||||
lexical_absolute_path(&absolute)
|
||||
}
|
||||
|
||||
fn same_path_identity(left: &Path, right: &Path) -> Result<bool> {
|
||||
let left = normalized_path_identity(left)?;
|
||||
let right = normalized_path_identity(right)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let left = left.to_string_lossy().replace('/', "\\");
|
||||
let right = right.to_string_lossy().replace('/', "\\");
|
||||
Ok(left.eq_ignore_ascii_case(&right))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Ok(left == right)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_backup_codex_dir(meta: &BackupMeta, codex_dir: &Path) -> Result<()> {
|
||||
let recorded = meta.codex_dir.trim();
|
||||
if recorded.is_empty() {
|
||||
return Err(CodexxError::Config("备份元数据缺少 CODEX_HOME".to_string()));
|
||||
}
|
||||
if same_path_identity(Path::new(recorded), codex_dir)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"备份属于其他 CODEX_HOME,拒绝恢复:备份为 {},当前为 {}",
|
||||
Path::new(recorded).display(),
|
||||
codex_dir.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn backup_root() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("backups"))
|
||||
}
|
||||
@@ -138,7 +216,3 @@ pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
|
||||
Ok(backups()?.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::backups::create_backup;
|
||||
use crate::config_path;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{parse_toml_document, read_to_string_if_exists, write_text};
|
||||
use crate::file_io::parse_toml_document;
|
||||
#[cfg(test)]
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::Path;
|
||||
use toml_edit::{DocumentMut, Item, Table};
|
||||
|
||||
@@ -28,14 +31,18 @@ fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
|
||||
|
||||
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
|
||||
/// header. Only the exact legacy keys with Markdown path values are touched.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn migrated_legacy_prompt_config_text(cfg: &Path, text: &str) -> Result<Option<String>> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let mut doc = parse_toml_document(cfg, text)?;
|
||||
let nested_instruction = tui_table(&doc)
|
||||
.and_then(|tui| tui.get(MODEL_AVAILABILITY_NUX_KEY))
|
||||
.and_then(|item| item.as_table())
|
||||
@@ -46,12 +53,10 @@ pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(MODEL_AVAILABILITY_NUX_KEY)));
|
||||
|
||||
if !nested_instruction && !tui_instruction && !nux_as_instruction {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let root_instruction_exists = doc.as_table().contains_key(INSTRUCTION_KEY);
|
||||
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
|
||||
|
||||
let removed_nested = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
@@ -75,7 +80,19 @@ pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
doc.as_table_mut().insert(INSTRUCTION_KEY, instruction);
|
||||
}
|
||||
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
Ok(Some(doc.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_legacy_prompt_config_locked(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let original = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, original.as_deref())?;
|
||||
let Some(migrated) = migrated_legacy_prompt_config_text(&cfg, &text)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
|
||||
|
||||
atomic_write_if_unchanged(&cfg, original.as_deref(), migrated.as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use toml_edit::{value, DocumentMut};
|
||||
|
||||
const CONTEXT_WINDOW: &str = "model_context_window";
|
||||
const COMPACT_TOKEN_LIMIT: &str = "model_auto_compact_token_limit";
|
||||
const ONE_MILLION: i64 = 1_000_000;
|
||||
const DEFAULT_COMPACT_LIMIT: i64 = 900_000;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContextWindowValues {
|
||||
pub(crate) context_window: Option<i64>,
|
||||
pub(crate) compact_token_limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContextWindowConfig {
|
||||
pub(crate) config_text: String,
|
||||
pub(crate) enabled: bool,
|
||||
#[serde(flatten)]
|
||||
pub(crate) values: ContextWindowValues,
|
||||
}
|
||||
|
||||
fn read_integer(doc: &DocumentMut, key: &str) -> Result<Option<i64>> {
|
||||
doc.get(key)
|
||||
.map(|item| {
|
||||
item.as_integer()
|
||||
.ok_or_else(|| CodexxError::Config(format!("{key} 必须为整数")))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn write_integer(doc: &mut DocumentMut, key: &str, number: i64) {
|
||||
// Keep existing inline comments and spacing when replacing a setting.
|
||||
let decor = doc
|
||||
.get(key)
|
||||
.and_then(|item| item.as_value())
|
||||
.map(|value| value.decor().clone());
|
||||
doc[key] = value(number);
|
||||
if let Some(decor) = decor {
|
||||
*doc[key].as_value_mut().expect("integer value").decor_mut() = decor;
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_integer(doc: &mut DocumentMut, key: &str, number: Option<i64>) {
|
||||
if let Some(number) = number {
|
||||
write_integer(doc, key, number);
|
||||
} else {
|
||||
doc.as_table_mut().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits a supplied draft only; this never reads or writes the live configuration.
|
||||
/// `enabled = None` parses the checkbox state after a manual TOML edit.
|
||||
pub(crate) fn update_codex_context_window_inner(
|
||||
config_text: String,
|
||||
enabled: Option<bool>,
|
||||
previous_values: Option<ContextWindowValues>,
|
||||
) -> Result<ContextWindowConfig> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|error| CodexxError::Toml {
|
||||
path: "config.toml".to_string(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
|
||||
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
|
||||
|
||||
match enabled {
|
||||
Some(true) => {
|
||||
write_integer(&mut doc, CONTEXT_WINDOW, ONE_MILLION);
|
||||
if compact_token_limit.is_none() {
|
||||
write_integer(&mut doc, COMPACT_TOKEN_LIMIT, DEFAULT_COMPACT_LIMIT);
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
// Only undo the 1M preset. A manual edit to either value wins.
|
||||
if context_window == Some(ONE_MILLION) {
|
||||
restore_integer(
|
||||
&mut doc,
|
||||
CONTEXT_WINDOW,
|
||||
previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.context_window),
|
||||
);
|
||||
}
|
||||
let expected_compact_limit = previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.compact_token_limit)
|
||||
.unwrap_or(DEFAULT_COMPACT_LIMIT);
|
||||
if compact_token_limit == Some(expected_compact_limit) {
|
||||
restore_integer(
|
||||
&mut doc,
|
||||
COMPACT_TOKEN_LIMIT,
|
||||
previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.compact_token_limit),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
|
||||
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
|
||||
Ok(ContextWindowConfig {
|
||||
config_text: if enabled.is_none() {
|
||||
config_text
|
||||
} else {
|
||||
doc.to_string()
|
||||
},
|
||||
enabled: context_window == Some(ONE_MILLION),
|
||||
values: ContextWindowValues {
|
||||
context_window,
|
||||
compact_token_limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enables_at_top_level_and_preserves_unrelated_tables_and_comments() {
|
||||
let original = "# My Codex settings\nmodel = \"gpt-5.5\" # keep model\n\n[mcp_servers.notes]\ncommand = \"notes\"\nmodel_context_window = 120000\n\n[model_providers.custom]\nbase_url = \"https://api.example.test/v1\"\n";
|
||||
let result = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let doc = result.config_text.parse::<DocumentMut>().unwrap();
|
||||
assert_eq!(doc[CONTEXT_WINDOW].as_integer(), Some(ONE_MILLION));
|
||||
assert_eq!(
|
||||
doc[COMPACT_TOKEN_LIMIT].as_integer(),
|
||||
Some(DEFAULT_COMPACT_LIMIT)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["notes"][CONTEXT_WINDOW].as_integer(),
|
||||
Some(120000)
|
||||
);
|
||||
assert!(result.config_text.contains("# My Codex settings"));
|
||||
assert!(result
|
||||
.config_text
|
||||
.contains("model = \"gpt-5.5\" # keep model"));
|
||||
assert!(result
|
||||
.config_text
|
||||
.contains("base_url = \"https://api.example.test/v1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_underscores_and_quoted_keys_without_reformatting_the_draft() {
|
||||
let original = "\"model_context_window\" = 1_000_000 # large\nmodel_auto_compact_token_limit = 850_000\n";
|
||||
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
assert!(result.enabled);
|
||||
assert_eq!(result.values.compact_token_limit, Some(850000));
|
||||
assert_eq!(result.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_context_values_in_tables_or_multiline_strings() {
|
||||
let original = "instructions = '''\nmodel_context_window = 1000000\n'''\n[profiles.large]\nmodel_context_window = 1000000\n";
|
||||
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
assert!(!result.enabled);
|
||||
assert_eq!(result.values.context_window, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_custom_compaction_when_enabling_and_disabling() {
|
||||
let original = "model_auto_compact_token_limit = 850000 # custom\n";
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
assert_eq!(enabled.values.compact_token_limit, Some(850000));
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert_eq!(disabled.values.context_window, None);
|
||||
assert_eq!(disabled.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_custom_context_and_existing_default_compaction_in_same_edit() {
|
||||
let original = "model_context_window = 256000 # original\nmodel_auto_compact_token_limit = 900000 # intentional\n";
|
||||
let parsed = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let disabled = update_codex_context_window_inner(
|
||||
enabled.config_text,
|
||||
Some(false),
|
||||
Some(parsed.values),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disabled.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_saved_preset_removes_both_default_values() {
|
||||
let original = "model = \"gpt-5.5\"\n";
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert_eq!(disabled.config_text, original);
|
||||
assert!(!disabled.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_does_not_undo_new_manual_values() {
|
||||
let manual = "model_context_window = 500000\nmodel_auto_compact_token_limit = 420000\n";
|
||||
let result = update_codex_context_window_inner(
|
||||
manual.into(),
|
||||
Some(false),
|
||||
Some(ContextWindowValues {
|
||||
context_window: Some(256000),
|
||||
compact_token_limit: Some(200000),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.config_text, manual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_toml_and_invalid_top_level_field_types() {
|
||||
for original in [
|
||||
"[unfinished",
|
||||
"model_context_window = '1000000'",
|
||||
"model_auto_compact_token_limit = 'automatic'",
|
||||
] {
|
||||
assert!(update_codex_context_window_inner(original.into(), Some(true), None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_draft_can_be_enabled_and_returned_to_blank() {
|
||||
let enabled = update_codex_context_window_inner(String::new(), Some(true), None).unwrap();
|
||||
assert!(enabled.enabled);
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert!(disabled.config_text.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const TRAY_ID: &str = "codex-x-tray";
|
||||
const SHOW_WINDOW_MENU_ID: &str = "show-main-window";
|
||||
const QUIT_APP_MENU_ID: &str = "quit-codex-x";
|
||||
const SHOW_TRAY_MENU_ON_LEFT_CLICK: bool = cfg!(target_os = "macos");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_tray_mode(app: &tauri::AppHandle, dock_visible: bool) -> tauri::Result<()> {
|
||||
let policy = if dock_visible {
|
||||
tauri::ActivationPolicy::Regular
|
||||
} else {
|
||||
tauri::ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
// Attempt both operations: either one alone can leave a stale Dock entry on some macOS versions.
|
||||
let dock_result = app.set_dock_visibility(dock_visible);
|
||||
let policy_result = app.set_activation_policy(policy);
|
||||
dock_result?;
|
||||
policy_result
|
||||
}
|
||||
|
||||
fn retain_first_error(first_error: &mut Option<tauri::Error>, result: tauri::Result<()>) {
|
||||
if let Err(error) = result {
|
||||
if first_error.is_none() {
|
||||
*first_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut first_error = None;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
retain_first_error(&mut first_error, window.set_skip_taskbar(false));
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, app.show());
|
||||
|
||||
retain_first_error(&mut first_error, window.unminimize());
|
||||
retain_first_error(&mut first_error, window.show());
|
||||
retain_first_error(&mut first_error, window.set_focus());
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, set_macos_tray_mode(app, true));
|
||||
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_main_window(app: &tauri::AppHandle) {
|
||||
if let Err(error) = show_main_window(app) {
|
||||
eprintln!("failed to restore the Codex-X window: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> {
|
||||
let show_window =
|
||||
MenuItem::with_id(app, SHOW_WINDOW_MENU_ID, "显示 Codex-X", true, None::<&str>)?;
|
||||
let quit_app = MenuItem::with_id(app, QUIT_APP_MENU_ID, "退出 Codex-X", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_window, &quit_app])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
|
||||
.menu(&menu)
|
||||
.tooltip("Codex-X")
|
||||
.show_menu_on_left_click(SHOW_TRAY_MENU_ON_LEFT_CLICK)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
SHOW_WINDOW_MENU_ID => restore_main_window(app),
|
||||
QUIT_APP_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if !SHOW_TRAY_MENU_ON_LEFT_CLICK
|
||||
&& matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
)
|
||||
{
|
||||
restore_main_window(tray.app_handle());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
tray = tray.icon(icon);
|
||||
}
|
||||
|
||||
tray.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
|
||||
if window.label() != MAIN_WINDOW_LABEL {
|
||||
return;
|
||||
}
|
||||
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
if let Err(error) = window.hide() {
|
||||
eprintln!("failed to hide the Codex-X window: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(error) = window.set_skip_taskbar(true) {
|
||||
eprintln!("failed to remove Codex-X from the taskbar: {error}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Err(error) = set_macos_tray_mode(window.app_handle(), false) {
|
||||
eprintln!("failed to move Codex-X to the menu bar: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = event {
|
||||
restore_main_window(app);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (app, event);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::error::{CodexxError, Result};
|
||||
use chrono::Local;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use toml_edit::DocumentMut;
|
||||
@@ -107,6 +108,30 @@ pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path).map_err(|e| io_err(path, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let metadata = match fs::metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
if !metadata.is_file() || metadata.permissions().mode() & 0o777 == 0o600 {
|
||||
return Ok(());
|
||||
}
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(DocumentMut::new());
|
||||
@@ -117,7 +142,55 @@ pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
fn atomic_write_mode(path: &Path) -> Option<u32> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let sensitive = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| matches!(name, "config.toml" | "auth.json"));
|
||||
if sensitive {
|
||||
return Some(0o600);
|
||||
}
|
||||
fs::metadata(path)
|
||||
.ok()
|
||||
.map(|metadata| metadata.permissions().mode() & 0o777)
|
||||
}
|
||||
|
||||
fn create_atomic_temp(path: &Path, tmp: &Path, private: bool) -> Result<fs::File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
let mode = private.then_some(0o600).or_else(|| atomic_write_mode(path));
|
||||
if let Some(mode) = mode {
|
||||
options.mode(mode);
|
||||
let file = options.open(tmp).map_err(|error| io_err(tmp, error))?;
|
||||
fs::set_permissions(tmp, fs::Permissions::from_mode(mode))
|
||||
.map_err(|error| io_err(tmp, error))?;
|
||||
return Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
|
||||
options.open(tmp).map_err(|error| io_err(tmp, error))
|
||||
}
|
||||
|
||||
fn atomic_write_with_privacy_and_check<F>(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
private: bool,
|
||||
pre_commit: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -129,13 +202,29 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
WRITE_COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
{
|
||||
let mut file = fs::File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
|
||||
let result = (|| {
|
||||
let mut file = create_atomic_temp(path, &tmp, private)?;
|
||||
file.write_all(bytes).map_err(|e| io_err(&tmp, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&tmp, e))?;
|
||||
drop(file);
|
||||
pre_commit()?;
|
||||
fs::rename(&tmp, path).map_err(|e| io_err(path, e))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| io_err(path, e))?;
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, || Ok(()))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_checked<F>(path: &Path, bytes: &[u8], pre_commit: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, pre_commit)
|
||||
}
|
||||
|
||||
pub(crate) fn write_text(path: &Path, text: &str) -> Result<()> {
|
||||
@@ -147,6 +236,11 @@ pub(crate) fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
write_text(path, &(text + "\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn write_private_json(path: &Path, value: &Value) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
|
||||
atomic_write_with_privacy_and_check(path, (text + "\n").as_bytes(), true, || Ok(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -182,6 +276,91 @@ mod tests {
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_atomic_write_rejects_a_change_immediately_before_replace() {
|
||||
let dir = temp_dir("checked-race");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("write original file");
|
||||
|
||||
let error = atomic_write_checked(&path, b"codex-x", || {
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
Err(CodexxError::Config("stale snapshot".to_string()))
|
||||
})
|
||||
.expect_err("stale checked write must fail");
|
||||
|
||||
assert!(error.to_string().contains("stale snapshot"));
|
||||
assert_eq!(fs::read(&path).expect("read external value"), b"external");
|
||||
assert_eq!(fs::read_dir(&dir).expect("read directory").count(), 1);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_restricts_sensitive_codex_files_and_preserves_other_modes() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let auth = dir.join("auth.json");
|
||||
let script = dir.join("tool.sh");
|
||||
for path in [&config, &auth, &script] {
|
||||
fs::write(path, b"old").expect("seed file");
|
||||
}
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set config mode");
|
||||
fs::set_permissions(&auth, fs::Permissions::from_mode(0o644)).expect("set auth mode");
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("set script mode");
|
||||
|
||||
atomic_write(&config, b"new config").expect("replace config");
|
||||
atomic_write(&auth, b"new auth").expect("replace auth");
|
||||
atomic_write(&script, b"new script").expect("replace script");
|
||||
|
||||
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&config), 0o600);
|
||||
assert_eq!(mode(&auth), 0o600);
|
||||
assert_eq!(mode(&script), 0o755);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn harden_sensitive_permissions_updates_existing_files_without_creating_missing_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("harden-permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let missing = dir.join("auth.json");
|
||||
fs::write(&config, b"model = \"gpt\"\n").expect("seed config");
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set open mode");
|
||||
|
||||
harden_sensitive_file_permissions(&config).expect("harden config");
|
||||
harden_sensitive_file_permissions(&missing).expect("ignore missing auth");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&config).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert!(!missing.exists());
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_json_is_private_from_its_first_atomic_write() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("private-json");
|
||||
let path = dir.join("official-snapshot.json");
|
||||
|
||||
write_private_json(&path, &serde_json::json!({"token": "secret"}))
|
||||
.expect("write private JSON");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_directory_accepts_existing_and_missing_directories() {
|
||||
let root = temp_dir("ensure-directory");
|
||||
|
||||
+659
-133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write_checked, ensure_directory, io_err};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) struct LiveConfigLock {
|
||||
file: fs::File,
|
||||
}
|
||||
|
||||
impl Drop for LiveConfigLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.file.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_live_config_lock(codex_dir: &Path) -> Result<LiveConfigLock> {
|
||||
let tmp_dir = codex_dir.join("tmp");
|
||||
ensure_directory(&tmp_dir)?;
|
||||
let path = tmp_dir.join("codex-x-live-config.lock");
|
||||
if path.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex live 配置锁被同名目录占用: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
file.try_lock().map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"另一个 Codex-X 正在修改 Codex live 配置,请稍后重试: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
file.set_len(0).map_err(|error| io_err(&path, error))?;
|
||||
writeln!(file, "pid={}", std::process::id()).map_err(|error| io_err(&path, error))?;
|
||||
file.sync_all().map_err(|error| io_err(&path, error))?;
|
||||
Ok(LiveConfigLock { file })
|
||||
}
|
||||
|
||||
pub(crate) fn read_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn text_from_snapshot(path: &Path, snapshot: Option<&[u8]>) -> Result<String> {
|
||||
String::from_utf8(snapshot.unwrap_or_default().to_vec())
|
||||
.map_err(|_| CodexxError::Config(format!("{} 不是有效的 UTF-8 文本", path.display())))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_file_snapshot_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
if read_file_snapshot(path)?.as_deref() == expected {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"{} 已被其他程序修改,本次写入已取消,请刷新后重试",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_if_unchanged(
|
||||
path: &Path,
|
||||
expected: Option<&[u8]>,
|
||||
replacement: &[u8],
|
||||
) -> Result<()> {
|
||||
atomic_write_checked(path, replacement, || {
|
||||
ensure_file_snapshot_unchanged(path, expected)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn remove_file_if_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
ensure_file_snapshot_unchanged(path, expected)?;
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_file_snapshot_if_unchanged(
|
||||
path: &Path,
|
||||
expected_current: Option<&[u8]>,
|
||||
snapshot: Option<&[u8]>,
|
||||
) -> Result<()> {
|
||||
match snapshot {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, expected_current, bytes),
|
||||
None => remove_file_if_unchanged(path, expected_current),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AppliedFileChange {
|
||||
path: std::path::PathBuf,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
impl AppliedFileChange {
|
||||
pub(crate) fn rollback(&self) -> Result<()> {
|
||||
if !self.changed {
|
||||
return Ok(());
|
||||
}
|
||||
restore_file_snapshot_if_unchanged(
|
||||
&self.path,
|
||||
self.after.as_deref(),
|
||||
self.before.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_file_change(
|
||||
path: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
) -> Result<AppliedFileChange> {
|
||||
let changed = before != after;
|
||||
if changed {
|
||||
match after.as_deref() {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, before.as_deref(), bytes)?,
|
||||
None => remove_file_if_unchanged(path, before.as_deref())?,
|
||||
}
|
||||
}
|
||||
Ok(AppliedFileChange {
|
||||
path: path.to_path_buf(),
|
||||
before,
|
||||
after,
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn rollback_file_changes(changes: &[AppliedFileChange]) -> Result<()> {
|
||||
for change in changes.iter().filter(|change| change.changed) {
|
||||
ensure_file_snapshot_unchanged(&change.path, change.after.as_deref())?;
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for change in changes.iter().rev() {
|
||||
if let Err(error) = change.rollback() {
|
||||
failures.push(error.to_string());
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(failures.join(";")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fail_with_file_rollback<T>(
|
||||
error: CodexxError,
|
||||
changes: &[AppliedFileChange],
|
||||
) -> Result<T> {
|
||||
match rollback_file_changes(changes) {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Config(format!(
|
||||
"{error};文件回滚失败:{rollback_error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-live-config-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_rejects_a_second_writer_and_releases_on_drop() {
|
||||
let dir = temp_dir("lock");
|
||||
let first = acquire_live_config_lock(&dir).expect("acquire first lock");
|
||||
let error = acquire_live_config_lock(&dir)
|
||||
.err()
|
||||
.expect("second lock must fail");
|
||||
assert!(error.to_string().contains("另一个 Codex-X"));
|
||||
drop(first);
|
||||
acquire_live_config_lock(&dir).expect("lock is released on drop");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_checked_write_preserves_the_external_value() {
|
||||
let dir = temp_dir("stale");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("seed file");
|
||||
let old = read_file_snapshot(&path).expect("capture file");
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
|
||||
atomic_write_if_unchanged(&path, old.as_deref(), b"codex-x")
|
||||
.expect_err("stale write must fail");
|
||||
|
||||
assert_eq!(fs::read(&path).expect("read file"), b"external");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
#[cfg(test)]
|
||||
use chrono::Local;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
|
||||
@@ -33,3 +33,13 @@ pub(crate) fn app_home() -> Result<PathBuf> {
|
||||
Ok(home_dir()?.join(".codexx"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalized_path_scope(path: &Path) -> String {
|
||||
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let normalized = resolved.to_string_lossy().replace('\\', "/");
|
||||
if cfg!(target_os = "windows") {
|
||||
normalized.to_ascii_lowercase()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
+1223
-101
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
use super::store::normalize_prompt_filename;
|
||||
use super::store::{
|
||||
builtin_prompt_override_ids_inner, builtin_prompt_override_inner, normalize_prompt_filename,
|
||||
};
|
||||
use super::types::{
|
||||
BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt, GithubContentEntry,
|
||||
BuiltinPromptDetail, BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt,
|
||||
GithubContentEntry,
|
||||
};
|
||||
use crate::constants::{
|
||||
GITHUB_EXAMPLES_API, GITHUB_EXAMPLES_BASE, INSTRUCTION_54_CONTENT, INSTRUCTION_54_FILENAME,
|
||||
@@ -20,7 +23,8 @@ use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
|
||||
static BUILTIN_PROMPT_CACHE_LOCK: Mutex<()> = Mutex::new(());
|
||||
// Only serialize remote syncs. Local reads must stay available during downloads.
|
||||
static BUILTIN_PROMPT_SYNC_LOCK: Mutex<()> = Mutex::new(());
|
||||
const CATALOG_CDN_KEY: &str = "模板 CDN";
|
||||
const CATALOG_GITHUB_KEY: &str = "GitHub 模板目录";
|
||||
const PROMPT_CDN_KEY: &str = "模板 CDN";
|
||||
@@ -140,6 +144,42 @@ pub(crate) fn stable_remote_prompt_id(filename: &str) -> String {
|
||||
}
|
||||
|
||||
fn prompt_display_meta(filename: &str) -> (String, String, String) {
|
||||
let curated = match filename.to_ascii_lowercase().as_str() {
|
||||
"software-development-maintainer.md" => Some((
|
||||
"长期维护工程师",
|
||||
"正式项目的最小改动、复用、测试与安全规范",
|
||||
"软件开发",
|
||||
)),
|
||||
"software-development-debugging.md" => Some((
|
||||
"系统化调试与根因修复",
|
||||
"从稳定复现、证据采集到最小修复与回归验证",
|
||||
"软件开发",
|
||||
)),
|
||||
"software-development-code-review.md" => Some((
|
||||
"严格代码审查",
|
||||
"按严重级别发现缺陷、回归、安全风险与测试缺口",
|
||||
"软件开发",
|
||||
)),
|
||||
"writing-clarity-editor.md" => Some((
|
||||
"清晰表达与润色",
|
||||
"保留原意与事实,优化中文或英文的结构、语气和可读性",
|
||||
"写作辅助",
|
||||
)),
|
||||
"writing-technical-docs.md" => Some((
|
||||
"技术文档写作",
|
||||
"基于代码和事实编写 README、指南、API 与变更文档",
|
||||
"写作辅助",
|
||||
)),
|
||||
"writing-structured-draft.md" => Some((
|
||||
"结构化长文起草",
|
||||
"把零散材料整理为提纲清晰、论证连贯的完整初稿",
|
||||
"写作辅助",
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((title, subtitle, badge)) = curated {
|
||||
return (title.to_string(), subtitle.to_string(), badge.to_string());
|
||||
}
|
||||
if let Some(meta) = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
@@ -266,6 +306,13 @@ pub(crate) fn delete_cached_prompt_ids(
|
||||
|
||||
fn prune_builtin_prompt_cache(active_ids: &HashSet<String>) -> Result<usize> {
|
||||
let mut conn = open_db()?;
|
||||
prune_builtin_prompt_cache_on_connection(&mut conn, active_ids)
|
||||
}
|
||||
|
||||
fn prune_builtin_prompt_cache_on_connection(
|
||||
conn: &mut Connection,
|
||||
active_ids: &HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -276,7 +323,14 @@ fn prune_builtin_prompt_cache(active_ids: &HashSet<String>) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
for id in stale_ids {
|
||||
deleted += transaction
|
||||
.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
|
||||
.execute(
|
||||
"DELETE FROM builtin_prompt_cache
|
||||
WHERE id = ?1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM builtin_prompt_overrides WHERE template_id = ?1
|
||||
)",
|
||||
[id],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
transaction
|
||||
@@ -455,6 +509,22 @@ fn fetch_github_prompt_catalog() -> Result<RemotePromptCatalog> {
|
||||
fetch_first_valid(&sources, parse_prompt_catalog)
|
||||
}
|
||||
|
||||
fn confirm_prompt_catalog_with<Fetch>(
|
||||
catalog: RemotePromptCatalog,
|
||||
fetch_authoritative: Fetch,
|
||||
) -> (RemotePromptCatalog, bool)
|
||||
where
|
||||
Fetch: FnOnce() -> Result<RemotePromptCatalog>,
|
||||
{
|
||||
if catalog.authoritative {
|
||||
return (catalog, false);
|
||||
}
|
||||
match fetch_authoritative() {
|
||||
Ok(authoritative) => (authoritative, false),
|
||||
Err(_) => (catalog, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> BuiltinPromptStatus {
|
||||
let (title, subtitle, badge) = prompt_display_meta(&cache.filename);
|
||||
BuiltinPromptStatus {
|
||||
@@ -470,6 +540,7 @@ fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> Builti
|
||||
sync_issue: None,
|
||||
checked_at: Some(cache.checked_at),
|
||||
message: message.to_string(),
|
||||
customized: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +597,7 @@ fn refresh_builtin_prompt_from_source(
|
||||
"已是最新在线模板"
|
||||
}
|
||||
.to_string(),
|
||||
customized: false,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -557,6 +629,7 @@ fn refresh_builtin_prompt_from_source(
|
||||
"在线模板暂时不可用,且没有本地副本"
|
||||
}
|
||||
.to_string(),
|
||||
customized: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -576,9 +649,72 @@ fn bundled_prompt_status(meta: BundledPromptMeta, message: &str) -> BuiltinPromp
|
||||
sync_issue: None,
|
||||
checked_at: None,
|
||||
message: message.to_string(),
|
||||
customized: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn customized_prompt_status(id: &str, filename: &str) -> Result<BuiltinPromptStatus> {
|
||||
let mut status = if let Some(cache) = cached_builtin_prompt(id)? {
|
||||
prompt_status_from_cache(cache, "本地已修改,GitHub 同步已跳过")
|
||||
} else if let Some(meta) = bundled_prompt_meta(id) {
|
||||
bundled_prompt_status(meta, "本地已修改,GitHub 同步已跳过")
|
||||
} else {
|
||||
let (title, subtitle, badge) = prompt_display_meta(filename);
|
||||
BuiltinPromptStatus {
|
||||
id: id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
source_url: builtin_prompt_source_url(filename),
|
||||
cached: false,
|
||||
updated: false,
|
||||
content_source: "local".to_string(),
|
||||
sync_issue: None,
|
||||
checked_at: None,
|
||||
message: "本地已修改,GitHub 同步已跳过".to_string(),
|
||||
customized: true,
|
||||
}
|
||||
};
|
||||
status.customized = true;
|
||||
status.message = "本地已修改,GitHub 同步已跳过".to_string();
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn mark_customized_prompt_statuses(
|
||||
statuses: &mut [BuiltinPromptStatus],
|
||||
customized_ids: &HashSet<String>,
|
||||
) {
|
||||
for status in statuses {
|
||||
if customized_ids.contains(&status.id) {
|
||||
status.customized = true;
|
||||
status.message = "本地已修改,GitHub 同步已跳过".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn include_customized_prompt_statuses(
|
||||
statuses: &mut Vec<BuiltinPromptStatus>,
|
||||
customized_ids: &HashSet<String>,
|
||||
caches: Vec<CachedBuiltinPrompt>,
|
||||
) {
|
||||
for cache in caches {
|
||||
if customized_ids.contains(&cache.id)
|
||||
&& !statuses.iter().any(|status| status.id == cache.id)
|
||||
{
|
||||
statuses.push(prompt_status_from_cache(
|
||||
cache,
|
||||
"本地已修改,GitHub 同步已跳过",
|
||||
));
|
||||
}
|
||||
}
|
||||
mark_customized_prompt_statuses(statuses, customized_ids);
|
||||
}
|
||||
|
||||
fn should_sync_prompt_content(id: &str, customized_ids: &HashSet<String>) -> bool {
|
||||
!customized_ids.contains(id)
|
||||
}
|
||||
|
||||
pub(crate) fn cached_prompt_fallback_statuses(
|
||||
caches: Vec<CachedBuiltinPrompt>,
|
||||
) -> Vec<BuiltinPromptStatus> {
|
||||
@@ -621,47 +757,38 @@ pub(crate) fn cached_prompt_fallback_statuses(
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_status_inner() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
Ok(cached_prompt_fallback_statuses(cached_builtin_prompts()?))
|
||||
let customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
let mut statuses = cached_prompt_fallback_statuses(cached_builtin_prompts()?);
|
||||
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
active_remote_builtin_prompt_id: impl FnOnce() -> Option<String>,
|
||||
) -> Result<Vec<BuiltinPromptStatus>> {
|
||||
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
|
||||
let _sync_guard = BUILTIN_PROMPT_SYNC_LOCK
|
||||
.lock()
|
||||
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
|
||||
let mut catalog = match fetch_prompt_catalog() {
|
||||
let customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
let catalog = match fetch_prompt_catalog() {
|
||||
Ok(catalog) => catalog,
|
||||
Err(_) => {
|
||||
let mut statuses = builtin_prompt_status_inner()?;
|
||||
for status in &mut statuses {
|
||||
status.sync_issue = Some("catalog".to_string());
|
||||
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
|
||||
if !status.customized {
|
||||
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
|
||||
}
|
||||
}
|
||||
return Ok(statuses);
|
||||
}
|
||||
};
|
||||
let cached_before = cached_builtin_prompts()?;
|
||||
let mut catalog_confirmation_failed = false;
|
||||
if !catalog.authoritative {
|
||||
let cdn_ids = catalog
|
||||
.prompts
|
||||
.iter()
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let cache_may_be_newer = cached_before
|
||||
.iter()
|
||||
.any(|cache| !cdn_ids.contains(&cache.id));
|
||||
let bundled_missing = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.any(|meta| !cdn_ids.contains(meta.id));
|
||||
if cache_may_be_newer || bundled_missing {
|
||||
match fetch_github_prompt_catalog() {
|
||||
Ok(authoritative) => catalog = authoritative,
|
||||
Err(_) => catalog_confirmation_failed = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
// A successful CDN response can still contain an old @main directory. Always
|
||||
// confirm directory membership with GitHub so newly added and removed files
|
||||
// become visible immediately; keep the CDN result as the offline fallback.
|
||||
let (catalog, catalog_confirmation_failed) =
|
||||
confirm_prompt_catalog_with(catalog, fetch_github_prompt_catalog);
|
||||
let remote_ids = catalog
|
||||
.prompts
|
||||
.iter()
|
||||
@@ -674,29 +801,46 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
.collect::<HashSet<_>>();
|
||||
let mut statuses = Vec::new();
|
||||
for (id, filename) in catalog.prompts {
|
||||
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
|
||||
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
|
||||
if !should_sync_prompt_content(&id, &customized_ids) {
|
||||
statuses.push(customized_prompt_status(&id, &filename)?);
|
||||
} else {
|
||||
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
|
||||
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
|
||||
}
|
||||
}
|
||||
for meta in bundled_prompt_metas() {
|
||||
if !remote_ids.contains(meta.id) {
|
||||
statuses.push(bundled_prompt_status(
|
||||
meta,
|
||||
"在线目录暂未提供该模板,使用软件内置版本",
|
||||
));
|
||||
statuses.push(if customized_ids.contains(meta.id) {
|
||||
customized_prompt_status(meta.id, meta.filename)?
|
||||
} else {
|
||||
bundled_prompt_status(meta, "在线目录暂未提供该模板,使用软件内置版本")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if catalog.authoritative {
|
||||
let mut retained_ids = remote_ids.clone();
|
||||
for customized_id in &customized_ids {
|
||||
retained_ids.insert(customized_id.clone());
|
||||
if !remote_ids.contains(customized_id)
|
||||
&& !statuses.iter().any(|status| status.id == *customized_id)
|
||||
{
|
||||
if let Some(cache) = cached_builtin_prompt(customized_id)? {
|
||||
statuses.push(customized_prompt_status(customized_id, &cache.filename)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(active_id) = active_remote_builtin_prompt_id() {
|
||||
if !remote_ids.contains(&active_id) {
|
||||
if let Some(cache) = cached_builtin_prompt(&active_id)? {
|
||||
let mut status = prompt_status_from_cache(
|
||||
cache,
|
||||
"该在线模板已下架,当前配置继续使用本地副本",
|
||||
);
|
||||
status.content_source = "removed".to_string();
|
||||
statuses.push(status);
|
||||
if !statuses.iter().any(|status| status.id == active_id) {
|
||||
let mut status = prompt_status_from_cache(
|
||||
cache,
|
||||
"该在线模板已下架,当前配置继续使用本地副本",
|
||||
);
|
||||
status.content_source = "removed".to_string();
|
||||
statuses.push(status);
|
||||
}
|
||||
}
|
||||
retained_ids.insert(active_id);
|
||||
}
|
||||
@@ -717,6 +861,10 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
if catalog_confirmation_failed {
|
||||
mark_catalog_confirmation_failed(&mut statuses);
|
||||
}
|
||||
// Local edits can finish while remote requests are pending. Use their
|
||||
// latest IDs and retained cache entries when returning the catalog.
|
||||
let customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
include_customized_prompt_statuses(&mut statuses, &customized_ids, cached_builtin_prompts()?);
|
||||
let order = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -737,9 +885,8 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
pub(crate) fn builtin_prompt_content(
|
||||
template_id: &str,
|
||||
) -> Result<(String, String, String, String)> {
|
||||
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
|
||||
// Editing and enabling a template are local operations. Network refreshes
|
||||
// update SQLite separately, whose reads see the last committed cache entry.
|
||||
let id = if template_id.trim().is_empty() {
|
||||
"gpt5.5-unrestricted"
|
||||
} else {
|
||||
@@ -752,17 +899,12 @@ pub(crate) fn builtin_prompt_content(
|
||||
.map(|item| item.filename.clone())
|
||||
.or_else(|| bundled.map(|item| item.filename.to_string()))
|
||||
.ok_or_else(|| CodexxError::Config(format!("提示词模板不存在或尚未同步: {id}")))?;
|
||||
let trust = PromptContentTrust {
|
||||
cached: cached.as_ref().map(|item| item.content.as_str()),
|
||||
bundled: bundled.map(|item| item.content),
|
||||
};
|
||||
if let Ok((remote, source_url)) = fetch_remote_prompt(&filename, trust) {
|
||||
save_builtin_prompt_cache(id, &filename, &source_url, &remote)?;
|
||||
if let Some(content) = builtin_prompt_override_inner(id)? {
|
||||
return Ok((
|
||||
filename.clone(),
|
||||
format!("./{filename}"),
|
||||
remote,
|
||||
"在线最新".to_string(),
|
||||
content,
|
||||
"本地已修改".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(cache) = cached {
|
||||
@@ -774,7 +916,9 @@ pub(crate) fn builtin_prompt_content(
|
||||
));
|
||||
}
|
||||
let bundled = bundled.ok_or_else(|| {
|
||||
CodexxError::Config(format!("无法下载提示词且没有可用缓存: {template_id}"))
|
||||
CodexxError::Config(format!(
|
||||
"提示词尚无本地内容,请先同步 GitHub 模板: {template_id}"
|
||||
))
|
||||
})?;
|
||||
Ok((
|
||||
bundled.filename.to_string(),
|
||||
@@ -784,10 +928,112 @@ pub(crate) fn builtin_prompt_content(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_detail_inner(template_id: &str) -> Result<BuiltinPromptDetail> {
|
||||
let id = if template_id.trim().is_empty() {
|
||||
"gpt5.5-unrestricted"
|
||||
} else {
|
||||
template_id.trim()
|
||||
};
|
||||
let (filename, _, content, _) = builtin_prompt_content(id)?;
|
||||
let (title, _, _) = prompt_display_meta(&filename);
|
||||
Ok(BuiltinPromptDetail {
|
||||
id: id.to_string(),
|
||||
filename,
|
||||
title,
|
||||
content,
|
||||
customized: builtin_prompt_override_inner(id)?.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_prompt_detail_does_not_wait_for_background_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let id = "github-detail-during-sync";
|
||||
save_builtin_prompt_cache(
|
||||
id,
|
||||
"detail-during-sync.md",
|
||||
"https://example.test/prompt.md",
|
||||
"cached content",
|
||||
)
|
||||
.expect("cache template");
|
||||
super::super::store::save_builtin_prompt_override_inner(id, "local edit")
|
||||
.expect("save local edit");
|
||||
|
||||
let sync_guard = BUILTIN_PROMPT_SYNC_LOCK
|
||||
.lock()
|
||||
.expect("simulate pending sync");
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
sender.send(builtin_prompt_detail_inner(id)).unwrap();
|
||||
});
|
||||
let detail = receiver.recv_timeout(std::time::Duration::from_secs(1));
|
||||
// Release before asserting so a regression cannot leave a blocked worker.
|
||||
drop(sync_guard);
|
||||
reader.join().expect("finish detail reader");
|
||||
let conn = open_db().expect("open test store");
|
||||
conn.execute(
|
||||
"DELETE FROM builtin_prompt_overrides WHERE template_id = ?1",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
|
||||
.unwrap();
|
||||
|
||||
let detail = detail
|
||||
.expect("local detail must finish while sync is pending")
|
||||
.unwrap();
|
||||
assert_eq!(detail.content, "local edit");
|
||||
assert!(detail.customized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_and_bundled_prompt_content_stays_available_during_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let id = "github-cached-content-during-sync";
|
||||
save_builtin_prompt_cache(
|
||||
id,
|
||||
"cached-during-sync.md",
|
||||
"https://example.test/prompt.md",
|
||||
"last downloaded content",
|
||||
)
|
||||
.expect("cache online template");
|
||||
let before = cached_builtin_prompt(id).unwrap().unwrap().checked_at;
|
||||
let sync_guard = BUILTIN_PROMPT_SYNC_LOCK
|
||||
.lock()
|
||||
.expect("simulate pending sync");
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let result = (|| -> Result<_> {
|
||||
Ok((
|
||||
builtin_prompt_content(id)?,
|
||||
builtin_prompt_content("gpt5.5-unrestricted")?,
|
||||
))
|
||||
})();
|
||||
sender.send(result).unwrap();
|
||||
});
|
||||
let result = receiver.recv_timeout(std::time::Duration::from_secs(1));
|
||||
drop(sync_guard);
|
||||
reader.join().expect("finish content reader");
|
||||
let after = cached_builtin_prompt(id).unwrap().unwrap().checked_at;
|
||||
open_db()
|
||||
.unwrap()
|
||||
.execute("DELETE FROM builtin_prompt_cache WHERE id = ?1", [id])
|
||||
.unwrap();
|
||||
|
||||
let (cached, bundled) = result
|
||||
.expect("local content must not wait for sync")
|
||||
.unwrap();
|
||||
assert_eq!(cached.2, "last downloaded content");
|
||||
assert_eq!(cached.3, "本地缓存");
|
||||
assert_eq!(bundled.2, INSTRUCTION_CONTENT);
|
||||
assert_eq!(bundled.3, "打包内置");
|
||||
assert_eq!(before, after, "reading must not refresh the cache");
|
||||
}
|
||||
|
||||
const TEST_SOURCES: [RemoteSource<'static>; 2] = [
|
||||
RemoteSource::new(PROMPT_CDN_KEY, "https://cdn.test/prompt.md", None),
|
||||
RemoteSource::new(PROMPT_GITHUB_KEY, "https://github.test/prompt.md", None),
|
||||
@@ -802,6 +1048,10 @@ mod tests {
|
||||
content TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create prompt cache table");
|
||||
@@ -813,6 +1063,65 @@ mod tests {
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_saved_during_sync_survives_stale_catalog_pruning_and_returned_statuses() {
|
||||
let mut conn = prompt_cache_connection();
|
||||
for id in [
|
||||
"removed-but-edited",
|
||||
"removed-unedited",
|
||||
"remote-still-present",
|
||||
] {
|
||||
save_builtin_prompt_cache_on_connection(
|
||||
&conn,
|
||||
id,
|
||||
&format!("{id}.md"),
|
||||
"https://example.test/prompt.md",
|
||||
"downloaded content",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Sync started before this edit, so its original retained IDs omit it.
|
||||
let retained_at_sync_start = HashSet::from(["remote-still-present".to_string()]);
|
||||
conn.execute(
|
||||
"INSERT INTO builtin_prompt_overrides (template_id, content) VALUES (?1, ?2)",
|
||||
["removed-but-edited", "new local content"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let removed = prune_builtin_prompt_cache_on_connection(&mut conn, &retained_at_sync_start)
|
||||
.expect("prune the old catalog after the local save");
|
||||
assert_eq!(removed, 1);
|
||||
let cache = cached_builtin_prompt_from_connection(&conn, "removed-but-edited")
|
||||
.unwrap()
|
||||
.expect("keep the filename needed to reopen the saved edit");
|
||||
assert_eq!(cache.filename, "removed-but-edited.md");
|
||||
assert!(
|
||||
cached_builtin_prompt_from_connection(&conn, "removed-unedited")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let mut statuses = vec![prompt_status_from_cache(
|
||||
cached_builtin_prompt_from_connection(&conn, "remote-still-present")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
"synced",
|
||||
)];
|
||||
let customized_ids = HashSet::from(["removed-but-edited".to_string()]);
|
||||
include_customized_prompt_statuses(
|
||||
&mut statuses,
|
||||
&customized_ids,
|
||||
cached_builtin_prompts_from_connection(&conn).unwrap(),
|
||||
);
|
||||
assert_eq!(statuses.len(), 2);
|
||||
let edited = statuses
|
||||
.iter()
|
||||
.find(|status| status.id == "removed-but-edited")
|
||||
.expect("include the newly edited template in the sync response");
|
||||
assert!(edited.customized);
|
||||
assert!(edited.message.contains("本地已修改"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synced_remote_prompt_is_available_from_sqlite_after_restart() {
|
||||
let database_path = std::env::temp_dir().join(format!(
|
||||
@@ -853,6 +1162,110 @@ mod tests {
|
||||
std::fs::remove_file(database_path).expect("remove prompt cache database");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_remote_prompts_have_user_facing_metadata() {
|
||||
assert_eq!(
|
||||
prompt_display_meta("software-development-maintainer.md"),
|
||||
(
|
||||
"长期维护工程师".to_string(),
|
||||
"正式项目的最小改动、复用、测试与安全规范".to_string(),
|
||||
"软件开发".to_string(),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_display_meta("writing-technical-docs.md").2,
|
||||
"写作辅助"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_remote_prompt_ids_retain_category_prefixes() {
|
||||
assert!(stable_remote_prompt_id("software-development-debugging.md")
|
||||
.starts_with("github-software-development-debugging-"));
|
||||
assert!(stable_remote_prompt_id("writing-clarity-editor.md")
|
||||
.starts_with("github-writing-clarity-editor-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locally_modified_prompt_is_excluded_from_content_sync() {
|
||||
let customized_ids = HashSet::from(["edited-template".to_string()]);
|
||||
|
||||
assert!(!should_sync_prompt_content(
|
||||
"edited-template",
|
||||
&customized_ids
|
||||
));
|
||||
assert!(should_sync_prompt_content(
|
||||
"new-github-template",
|
||||
&customized_ids
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locally_modified_prompt_status_is_visible_after_restart() {
|
||||
let cache = CachedBuiltinPrompt {
|
||||
id: "edited-template".to_string(),
|
||||
filename: "edited-template.md".to_string(),
|
||||
source_url: "https://example.test/edited-template.md".to_string(),
|
||||
content: "upstream cache".to_string(),
|
||||
checked_at: "2026-07-28T00:00:00Z".to_string(),
|
||||
};
|
||||
let mut statuses = cached_prompt_fallback_statuses(vec![cache]);
|
||||
let customized_ids = HashSet::from(["edited-template".to_string()]);
|
||||
|
||||
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
|
||||
|
||||
let status = statuses
|
||||
.iter()
|
||||
.find(|status| status.id == "edited-template")
|
||||
.expect("customized cached prompt remains visible");
|
||||
assert!(status.customized);
|
||||
assert!(status.message.contains("同步已跳过"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_cdn_catalog_is_always_replaced_by_github_catalog() {
|
||||
let cdn = RemotePromptCatalog {
|
||||
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
|
||||
authoritative: false,
|
||||
};
|
||||
let github = RemotePromptCatalog {
|
||||
prompts: vec![
|
||||
("bundled".to_string(), "bundled.md".to_string()),
|
||||
(
|
||||
"github-software-development-debugging-test".to_string(),
|
||||
"software-development-debugging.md".to_string(),
|
||||
),
|
||||
],
|
||||
authoritative: true,
|
||||
};
|
||||
let mut calls = 0;
|
||||
|
||||
let (confirmed, failed) = confirm_prompt_catalog_with(cdn, || {
|
||||
calls += 1;
|
||||
Ok(github)
|
||||
});
|
||||
|
||||
assert_eq!(calls, 1);
|
||||
assert!(!failed);
|
||||
assert!(confirmed.authoritative);
|
||||
assert_eq!(confirmed.prompts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_github_catalog_keeps_cdn_fallback_for_retry() {
|
||||
let cdn = RemotePromptCatalog {
|
||||
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
|
||||
authoritative: false,
|
||||
};
|
||||
|
||||
let (fallback, failed) =
|
||||
confirm_prompt_catalog_with(cdn, || Err(CodexxError::Config("offline".to_string())));
|
||||
|
||||
assert!(failed);
|
||||
assert!(!fallback.authoritative);
|
||||
assert_eq!(fallback.prompts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syncing_remote_prompt_updates_the_existing_sqlite_row() {
|
||||
let conn = prompt_cache_connection();
|
||||
|
||||
@@ -2,8 +2,9 @@ use crate::constants::{
|
||||
AGENTS_FILENAME, AGENTS_MANAGED_BEGIN, AGENTS_MANAGED_END, AGENTS_TEMPLATE_PREFIX,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{io_err, read_to_string_if_exists, write_text};
|
||||
use std::fs;
|
||||
use crate::file_io::read_to_string_if_exists;
|
||||
#[cfg(test)]
|
||||
use crate::live_config::{apply_file_change, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn agents_path(codex_dir: &Path) -> PathBuf {
|
||||
@@ -31,7 +32,7 @@ pub(crate) fn managed_agents_bounds(content: &str) -> Result<Option<(usize, usiz
|
||||
Ok(Some((begins[0], ends[0] + AGENTS_MANAGED_END.len())))
|
||||
}
|
||||
|
||||
fn remove_managed_agents_block(content: &str) -> Result<(String, bool)> {
|
||||
pub(crate) fn remove_managed_agents_block_from_content(content: &str) -> Result<(String, bool)> {
|
||||
let Some((start, end)) = managed_agents_bounds(content)? else {
|
||||
return Ok((content.to_string(), false));
|
||||
};
|
||||
@@ -46,6 +47,23 @@ fn remove_managed_agents_block(content: &str) -> Result<(String, bool)> {
|
||||
Ok((merged, true))
|
||||
}
|
||||
|
||||
pub(crate) fn install_managed_agents_block_in_content(
|
||||
existing: &str,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<String> {
|
||||
let (base, _) = remove_managed_agents_block_from_content(existing)?;
|
||||
let managed = format!(
|
||||
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
|
||||
content.trim()
|
||||
);
|
||||
Ok(if base.trim().is_empty() {
|
||||
format!("{managed}\n")
|
||||
} else {
|
||||
format!("{}\n\n{managed}\n", base.trim_end())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_template_key_from_content(content: &str) -> Option<String> {
|
||||
let (start, end) = managed_agents_bounds(content).ok().flatten()?;
|
||||
content[start..end].lines().find_map(|line| {
|
||||
@@ -64,40 +82,33 @@ pub(crate) fn managed_agents_template_key(codex_dir: &Path) -> Result<Option<Str
|
||||
Ok(managed_agents_template_key_from_content(&content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_managed_agents_block(
|
||||
codex_dir: &Path,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let path = agents_path(codex_dir);
|
||||
let existing = read_to_string_if_exists(&path)?;
|
||||
let (base, _) = remove_managed_agents_block(&existing)?;
|
||||
let managed = format!(
|
||||
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
|
||||
content.trim()
|
||||
);
|
||||
let next = if base.trim().is_empty() {
|
||||
format!("{managed}\n")
|
||||
} else {
|
||||
format!("{}\n\n{managed}\n", base.trim_end())
|
||||
};
|
||||
write_text(&path, &next)
|
||||
let before = read_file_snapshot(&path)?;
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let next = install_managed_agents_block_in_content(&existing, template_key, content)?;
|
||||
apply_file_change(&path, before, Some(next.into_bytes()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn uninstall_managed_agents_block(codex_dir: &Path) -> Result<bool> {
|
||||
let path = agents_path(codex_dir);
|
||||
if !path.exists() {
|
||||
let before = read_file_snapshot(&path)?;
|
||||
if before.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let existing = read_to_string_if_exists(&path)?;
|
||||
let (next, removed) = remove_managed_agents_block(&existing)?;
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let (next, removed) = remove_managed_agents_block_from_content(&existing)?;
|
||||
if !removed {
|
||||
return Ok(false);
|
||||
}
|
||||
if next.trim().is_empty() {
|
||||
fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
|
||||
} else {
|
||||
write_text(&path, &next)?;
|
||||
}
|
||||
let after = (!next.trim().is_empty()).then(|| next.into_bytes());
|
||||
apply_file_change(&path, before, after)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,20 @@ mod store;
|
||||
mod types;
|
||||
|
||||
pub(crate) use catalog::{
|
||||
builtin_prompt_content, builtin_prompt_status_inner, bundled_prompt_meta, bundled_prompt_metas,
|
||||
refresh_builtin_prompts_with_active,
|
||||
builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
|
||||
bundled_prompt_meta, bundled_prompt_metas, refresh_builtin_prompts_with_active,
|
||||
};
|
||||
pub(crate) use managed_agents::{
|
||||
agents_path, install_managed_agents_block, managed_agents_bounds, managed_agents_template_key,
|
||||
uninstall_managed_agents_block,
|
||||
agents_path, install_managed_agents_block_in_content, managed_agents_bounds,
|
||||
managed_agents_template_key, remove_managed_agents_block_from_content,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use managed_agents::{install_managed_agents_block, uninstall_managed_agents_block};
|
||||
pub(crate) use store::{
|
||||
delete_prompt_inner, get_saved_prompt_inner, list_saved_prompts_inner,
|
||||
normalize_prompt_filename, save_prompt_inner,
|
||||
normalize_prompt_filename, save_builtin_prompt_override_inner, save_prompt_inner,
|
||||
};
|
||||
pub(crate) use types::{BuiltinPromptStatus, SavedPrompt};
|
||||
pub(crate) use types::{BuiltinPromptDetail, BuiltinPromptStatus, SavedPrompt};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use catalog::{
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::types::SavedPrompt;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::{now_rfc3339, open_db};
|
||||
use rusqlite::params;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn normalize_prompt_filename(input: &str, fallback: &str) -> String {
|
||||
let raw = input.trim().trim_end_matches(".md");
|
||||
@@ -107,6 +108,68 @@ pub(crate) fn delete_prompt_inner(id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn builtin_prompt_override_from_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
match conn.query_row(
|
||||
"SELECT content FROM builtin_prompt_overrides WHERE template_id = ?1",
|
||||
[template_id],
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_inner(template_id: &str) -> Result<Option<String>> {
|
||||
let conn = open_db()?;
|
||||
builtin_prompt_override_from_connection(&conn, template_id.trim())
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_ids_inner() -> Result<HashSet<String>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT template_id FROM builtin_prompt_overrides")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
rows.map(|row| row.map_err(|e| CodexxError::Database(e.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_builtin_prompt_override_on_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let id = template_id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(CodexxError::Config("提示词模板标识不能为空".to_string()));
|
||||
}
|
||||
if content.trim().is_empty() {
|
||||
return Err(CodexxError::Config("提示词内容不能为空".to_string()));
|
||||
}
|
||||
let now = now_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO builtin_prompt_overrides (template_id, content, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(template_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at",
|
||||
params![id, content, now],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn save_builtin_prompt_override_inner(template_id: &str, content: &str) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_builtin_prompt_override_on_connection(&conn, template_id, content)
|
||||
}
|
||||
|
||||
fn find_saved_prompt_by_content(content: &str) -> Result<Option<SavedPrompt>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
@@ -167,3 +230,83 @@ pub(super) fn find_saved_prompt_by_current_file(
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn override_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_can_be_edited_repeatedly() {
|
||||
let conn = override_connection();
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "first")
|
||||
.expect("save initial override");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "second")
|
||||
.expect("update override");
|
||||
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&conn, "template")
|
||||
.expect("read override")
|
||||
.as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_rejects_empty_content() {
|
||||
let conn = override_connection();
|
||||
let error = save_builtin_prompt_override_on_connection(&conn, "template", " \n")
|
||||
.expect_err("reject empty override");
|
||||
assert!(error.to_string().contains("内容不能为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_survives_database_reopen() {
|
||||
let database_path = std::env::temp_dir().join(format!(
|
||||
"codexx-prompt-override-{}-{}.sqlite",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time")
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let conn = Connection::open(&database_path).expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "local content")
|
||||
.expect("save override");
|
||||
}
|
||||
|
||||
let reopened = Connection::open(&database_path).expect("reopen override database");
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&reopened, "template")
|
||||
.expect("read persisted override")
|
||||
.as_deref(),
|
||||
Some("local content")
|
||||
);
|
||||
drop(reopened);
|
||||
std::fs::remove_file(database_path).expect("remove override database");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ pub(crate) struct BuiltinPromptStatus {
|
||||
pub(crate) sync_issue: Option<String>,
|
||||
pub(crate) checked_at: Option<String>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltinPromptDetail {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
use super::{
|
||||
custom_provider_id, experimental_bearer_token_from_doc, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, open_store, upsert_provider_on_connection, ProviderUpsertKind,
|
||||
ProviderUpsertMode, SavedProvider,
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
experimental_bearer_token_from_doc, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, open_store, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use crate::string_value;
|
||||
use crate::toml_utils::ensure_table;
|
||||
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use toml_edit::{DocumentMut, Table};
|
||||
use toml_edit::{value, DocumentMut, Item, Table};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -30,6 +32,7 @@ pub(crate) struct ImportResult {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialAuthCandidate {
|
||||
auth_json: String,
|
||||
config_text: Option<String>,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
@@ -91,6 +94,7 @@ pub(crate) struct CcSwitchCodexSection {
|
||||
pub(crate) wire_api: String,
|
||||
pub(crate) requires_openai_auth: bool,
|
||||
pub(crate) experimental_bearer_token: Option<String>,
|
||||
pub(crate) provider_table: Table,
|
||||
}
|
||||
|
||||
fn table_string(table: &Table, key: &str) -> Option<String> {
|
||||
@@ -134,6 +138,7 @@ pub(super) fn codex_section_from_table(
|
||||
.and_then(|item| item.as_bool())
|
||||
.unwrap_or(false),
|
||||
experimental_bearer_token: table_string(table, "experimental_bearer_token"),
|
||||
provider_table: table.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,79 +165,130 @@ fn select_ccswitch_section_for_row(
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<CcSwitchCodexSection> {
|
||||
let provider_id = custom_provider_id(&row.id);
|
||||
if let Some(section) = global_sections.get(&provider_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
if let Some(section) = global_sections.get(row.id.trim()) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let model = string_value(&doc, "model");
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
let doc = config_text.parse::<DocumentMut>().ok();
|
||||
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = doc.as_ref() {
|
||||
let model = string_value(doc, "model");
|
||||
let active_provider = string_value(doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch templates store each third-party provider under
|
||||
// `[model_providers.custom]` in that row's own complete config.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch/custom templates often store every third-party provider
|
||||
// under `[model_providers.custom]`. Only use it when the row's own config
|
||||
// explicitly activates custom or contains no other provider identity.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = global_sections.get(exact_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let doc = doc?;
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
doc.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|base_url| CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
model,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: experimental_bearer_token_from_doc(
|
||||
&doc,
|
||||
active_provider.as_deref(),
|
||||
),
|
||||
.map(|base_url| {
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
let token = experimental_bearer_token_from_doc(&doc, active_provider.as_deref());
|
||||
let mut provider_table = Table::new();
|
||||
provider_table["base_url"] = value(base_url.clone());
|
||||
provider_table["wire_api"] = value("responses");
|
||||
provider_table["requires_openai_auth"] = value(false);
|
||||
if let Some(token) = token.as_deref() {
|
||||
provider_table["experimental_bearer_token"] = value(token);
|
||||
}
|
||||
CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url,
|
||||
model: string_value(&doc, "model"),
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: token,
|
||||
provider_table,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn ccswitch_provider_template(
|
||||
settings: &Value,
|
||||
section: &CcSwitchCodexSection,
|
||||
provider_name: &str,
|
||||
model: &str,
|
||||
) -> Option<String> {
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let mut doc = if config_text.trim().is_empty() {
|
||||
DocumentMut::new()
|
||||
} else {
|
||||
config_text.parse::<DocumentMut>().ok()?
|
||||
};
|
||||
let provider_id = section.id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if string_value(&doc, "model_provider").as_deref() != Some(provider_id) {
|
||||
doc["model_provider"] = value(provider_id);
|
||||
}
|
||||
if string_value(&doc, "model").as_deref() != Some(model) {
|
||||
doc["model"] = value(model);
|
||||
}
|
||||
let providers = ensure_table(doc.as_table_mut(), "model_providers").ok()?;
|
||||
if providers
|
||||
.get(provider_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.is_none()
|
||||
{
|
||||
let mut table = section.provider_table.clone();
|
||||
if table_string(&table, "name").is_none() && !provider_name.trim().is_empty() {
|
||||
table["name"] = value(provider_name.trim());
|
||||
}
|
||||
providers.insert(provider_id, Item::Table(table));
|
||||
}
|
||||
strip_provider_bearer_tokens(&mut doc);
|
||||
Some(doc.to_string().trim_end().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn build_ccswitch_codex_provider(
|
||||
row: &CcSwitchCodexRow,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
@@ -240,17 +296,23 @@ pub(crate) fn build_ccswitch_codex_provider(
|
||||
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
|
||||
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
|
||||
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
|
||||
let provider_name = section
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
let name = row.name.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| row.id.clone());
|
||||
let model = section.model.clone()?;
|
||||
let toml_config = ccswitch_provider_template(&settings, §ion, &provider_name, &model)?;
|
||||
Some(SavedProvider {
|
||||
id: custom_provider_id(&row.id),
|
||||
provider_name: if row.name.trim().is_empty() {
|
||||
section.name.unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
},
|
||||
provider_name,
|
||||
base_url: section.base_url,
|
||||
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
|
||||
model,
|
||||
api_key,
|
||||
toml_config: None,
|
||||
toml_config: Some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
})
|
||||
@@ -314,6 +376,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
let transaction = local_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
|
||||
|
||||
for row in rows_vec {
|
||||
if is_official_ccswitch_row(&row) {
|
||||
@@ -327,15 +390,11 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
match build_ccswitch_codex_provider(&row, &global_sections) {
|
||||
Some(provider) => {
|
||||
let provider = normalize_saved_provider(provider)?;
|
||||
let result = upsert_provider_on_connection(
|
||||
&transaction,
|
||||
provider,
|
||||
ProviderUpsertMode::Imported,
|
||||
)?;
|
||||
let result =
|
||||
upsert_ccswitch_provider_on_connection(&transaction, provider, row.id.trim())?;
|
||||
match result.kind {
|
||||
ProviderUpsertKind::Added => added += 1,
|
||||
ProviderUpsertKind::Updated => updated += 1,
|
||||
ProviderUpsertKind::Merged => merged += 1,
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
@@ -348,6 +407,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
}
|
||||
}
|
||||
}
|
||||
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -385,13 +445,22 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
CodexxError::Database(format!("打开 cc-switch 数据库失败 {}: {e}", db.display()))
|
||||
})?;
|
||||
|
||||
let provider_columns = table_column_set(&conn, "providers")?;
|
||||
let official_filter = if provider_columns.contains("category") {
|
||||
"id = 'codex-official' OR category = 'official'"
|
||||
} else {
|
||||
// Older cc-switch databases predate the category column. The stable
|
||||
// codex-official id is still enough to identify the official row.
|
||||
"id = 'codex-official'"
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT id, name, settings_config FROM providers
|
||||
WHERE app_type = 'codex' AND ({official_filter})
|
||||
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
|
||||
LIMIT 1"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, settings_config FROM providers
|
||||
WHERE app_type = 'codex' AND (id = 'codex-official' OR category = 'official')
|
||||
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
|
||||
LIMIT 1",
|
||||
)
|
||||
.prepare(&query)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let mut rows = stmt
|
||||
@@ -426,9 +495,12 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
CodexxError::Database("cc-switch official provider 缺少 auth object".to_string())
|
||||
})?;
|
||||
|
||||
let model = settings
|
||||
let config_text = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let model = config_text
|
||||
.as_deref()
|
||||
.and_then(|text| text.parse::<DocumentMut>().ok())
|
||||
.and_then(|doc| string_value(&doc, "model"));
|
||||
|
||||
@@ -437,7 +509,195 @@ pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
|
||||
Ok(Some(OfficialAuthCandidate {
|
||||
auth_json,
|
||||
config_text,
|
||||
model,
|
||||
source: format!("cc-switch:{name}:{id}"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
|
||||
let settings_config = json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-from-auth"},
|
||||
"config": r#"# keep-imported-comment
|
||||
model_provider = "custom"
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "xhigh"
|
||||
service_tier = "priority"
|
||||
experimental_bearer_token = "sk-top-level"
|
||||
notify = ["C:\\Users\\Thy\\codex-computer-use.exe", "turn-ended"]
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Sky2api"
|
||||
base_url = "https://proxy.example.com/v1/"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-from-config"
|
||||
request_max_retries = 7
|
||||
|
||||
[model_providers.other]
|
||||
name = "Other provider"
|
||||
base_url = "https://other.example.com/v1"
|
||||
experimental_bearer_token = "sk-other"
|
||||
|
||||
[projects."/work/project"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[desktop]
|
||||
followUpQueueMode = "queue"
|
||||
localeOverride = "zh-CN"
|
||||
|
||||
[windows]
|
||||
sandbox = "elevated"
|
||||
shell_path = 'D:\Program Files\PowerShell\7\pwsh.exe'
|
||||
|
||||
[plugins."browser@openai-bundled"]
|
||||
enabled = true
|
||||
|
||||
[features]
|
||||
js_repl = false
|
||||
|
||||
[shell_environment_policy.set]
|
||||
CODEX_HOME = 'C:\Users\Thy\.codex'
|
||||
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
"#,
|
||||
})
|
||||
.to_string();
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "magicai-123".to_string(),
|
||||
name: " Sky2_free ".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
};
|
||||
|
||||
let provider =
|
||||
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
|
||||
assert_eq!(provider.id, "magicai-123");
|
||||
assert_eq!(provider.provider_name, "Sky2api");
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(provider.model, "gpt-5.6-sol");
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-from-auth"));
|
||||
assert_eq!(provider.wire_api, "responses");
|
||||
assert!(!provider.requires_openai_auth);
|
||||
|
||||
let text = provider.toml_config.expect("complete provider TOML");
|
||||
let doc = text.parse::<DocumentMut>().expect("parse provider TOML");
|
||||
assert!(text.contains("# keep-imported-comment"));
|
||||
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
|
||||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("xhigh"));
|
||||
assert_eq!(doc["service_tier"].as_str(), Some("priority"));
|
||||
assert_eq!(doc["notify"].as_array().map(|values| values.len()), Some(2));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("Sky2api")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://proxy.example.com/v1/")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["other"]["base_url"].as_str(),
|
||||
Some("https://other.example.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["projects"]["/work/project"]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
assert_eq!(doc["desktop"]["followUpQueueMode"].as_str(), Some("queue"));
|
||||
assert_eq!(doc["desktop"]["localeOverride"].as_str(), Some("zh-CN"));
|
||||
assert_eq!(doc["windows"]["sandbox"].as_str(), Some("elevated"));
|
||||
assert_eq!(
|
||||
doc["windows"]["shell_path"].as_str(),
|
||||
Some(r"D:\Program Files\PowerShell\7\pwsh.exe")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["plugins"]["browser@openai-bundled"]["enabled"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
doc["shell_environment_policy"]["set"]["CODEX_HOME"].as_str(),
|
||||
Some(r"C:\Users\Thy\.codex")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["docs"]["command"].as_str(),
|
||||
Some("docs-server")
|
||||
);
|
||||
assert!(doc.get("experimental_bearer_token").is_none());
|
||||
assert!(doc["model_providers"]
|
||||
.as_table()
|
||||
.expect("model providers table")
|
||||
.iter()
|
||||
.all(|(_, item)| item
|
||||
.as_table()
|
||||
.is_none_or(|table| table.get("experimental_bearer_token").is_none())));
|
||||
assert!(!text.contains("sk-from-auth"));
|
||||
assert!(!text.contains("experimental_bearer_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_row_without_a_model_is_not_silently_downgraded() {
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "missing-model".to_string(),
|
||||
name: "Database label".to_string(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-test"},
|
||||
"config": r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "TOML label"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
"#,
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_row_config_does_not_create_a_sparse_provider() {
|
||||
let section = codex_sections_from_config(
|
||||
r#"model_provider = "broken-row"
|
||||
model = "gpt-5.5"
|
||||
|
||||
[model_providers.broken-row]
|
||||
name = "Recovered only from another row"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
"#,
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("global provider section");
|
||||
let mut global_sections = HashMap::new();
|
||||
global_sections.insert(section.id.clone(), section);
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "broken-row".to_string(),
|
||||
name: "Broken row".to_string(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-must-not-import"},
|
||||
"config": "model = ["
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
mod official_auth;
|
||||
pub(crate) mod official_profiles;
|
||||
mod selection;
|
||||
mod store;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -21,29 +24,46 @@ pub(crate) use connection::{
|
||||
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
|
||||
ProviderModelsResult,
|
||||
};
|
||||
pub(crate) use live::detected_live_custom_provider;
|
||||
pub(crate) use live::{
|
||||
build_provider_toml_draft_inner, delete_saved_provider_inner, save_active_provider_inner,
|
||||
save_official_config_inner, save_provider_toml_config_inner, switch_provider_inner,
|
||||
OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use live::{
|
||||
detected_live_custom_provider, save_provider_toml_config_with_pre_persist,
|
||||
reset_official_provider_inner, restore_official_provider_inner,
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_inner,
|
||||
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
|
||||
};
|
||||
pub(crate) use live::{
|
||||
save_official_config_inner, save_provider_toml_config_inner, switch_official_provider_inner,
|
||||
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
#[cfg(test)]
|
||||
pub(crate) use official_auth::{
|
||||
capture_live_chatgpt_config, get_official_config_draft_inner, official_snapshot_path_for_test,
|
||||
};
|
||||
pub(crate) use official_auth::{
|
||||
document_is_official, official_auth_available, OfficialConfigDraft,
|
||||
};
|
||||
pub(crate) use selection::{
|
||||
clear_active_provider_on_connection, clear_provider_selections_on_connection,
|
||||
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::{
|
||||
canonical_provider_base_url, merge_duplicate_provider_identities, provider_by_id_on_connection,
|
||||
provider_identity, save_manual_provider_on_connection,
|
||||
canonical_provider_base_url, provider_by_id_on_connection, provider_identity,
|
||||
save_manual_provider_on_connection, upsert_provider_on_connection, ProviderUpsertMode,
|
||||
};
|
||||
pub(crate) use store::{
|
||||
custom_provider_id, delete_provider_inner, experimental_bearer_token_from_doc,
|
||||
list_saved_providers_inner, list_saved_providers_on_connection, normalize_saved_provider,
|
||||
reserved_codex_provider_id, save_detected_provider_inner, save_provider_inner,
|
||||
upsert_provider_on_connection, ProviderUpsertKind, ProviderUpsertMode, SavedProvider,
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
|
||||
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
matching_saved_provider_ids_for_live, normalize_saved_provider,
|
||||
normalize_saved_provider_for_save, provider_template_from_document, reserved_codex_provider_id,
|
||||
rollback_provider_store_inner, save_detected_provider_with_rollback_inner, save_provider_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
|
||||
ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
let mut conn = crate::app_db::open()?;
|
||||
store::merge_duplicate_provider_identities(&mut conn)?;
|
||||
Ok(conn)
|
||||
crate::app_db::open()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::now_rfc3339;
|
||||
use crate::paths::normalized_path_scope;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
fn selected_provider_id_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
conn.query_row(
|
||||
"SELECT provider_id
|
||||
FROM active_provider_selections
|
||||
WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn remember_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
let provider_id = provider_id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return Err(CodexxError::Config("当前供应商 ID 不能为空".to_string()));
|
||||
}
|
||||
let exists = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM providers WHERE id = ?1)",
|
||||
[provider_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !exists {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法记录当前供应商,未找到 ID {provider_id}"
|
||||
)));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO active_provider_selections (codex_dir, provider_id, updated_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(codex_dir) DO UPDATE SET
|
||||
provider_id = excluded.provider_id,
|
||||
updated_at = excluded.updated_at",
|
||||
params![normalized_path_scope(codex_dir), provider_id, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_provider_selections_on_connection(
|
||||
conn: &Connection,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
candidate_ids: &[String],
|
||||
) -> Result<Option<String>> {
|
||||
let candidates = candidate_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let selected = selected_provider_id_on_connection(conn, codex_dir)?;
|
||||
if let Some(selected) = selected.as_deref() {
|
||||
if candidates.contains(selected) {
|
||||
return Ok(Some(selected.to_string()));
|
||||
}
|
||||
clear_active_provider_on_connection(conn, codex_dir)?;
|
||||
}
|
||||
|
||||
if candidates.len() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
let provider_id = candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("one candidate must be present");
|
||||
remember_active_provider_on_connection(conn, codex_dir, provider_id)?;
|
||||
Ok(Some(provider_id.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open selection test database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE providers (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO providers (id) VALUES ('original'), ('copy'), ('other');",
|
||||
)
|
||||
.expect("create selection test schema");
|
||||
conn
|
||||
}
|
||||
|
||||
fn scope(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("codex-x-selection-{name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicate_does_not_replace_the_remembered_current_provider() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("duplicate");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember original provider");
|
||||
|
||||
let active = reconcile_active_provider_on_connection(
|
||||
&conn,
|
||||
&codex_dir,
|
||||
&["original".to_string(), "copy".to_string()],
|
||||
)
|
||||
.expect("reconcile duplicate profiles");
|
||||
|
||||
assert_eq!(active.as_deref(), Some("original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selections_are_independent_for_each_codex_directory() {
|
||||
let conn = test_connection();
|
||||
let first = scope("first");
|
||||
let second = scope("second");
|
||||
remember_active_provider_on_connection(&conn, &first, "original")
|
||||
.expect("remember first selection");
|
||||
remember_active_provider_on_connection(&conn, &second, "copy")
|
||||
.expect("remember second selection");
|
||||
let candidates = ["original".to_string(), "copy".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &first, &candidates)
|
||||
.expect("reconcile first selection")
|
||||
.as_deref(),
|
||||
Some("original")
|
||||
);
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &second, &candidates)
|
||||
.expect("reconcile second selection")
|
||||
.as_deref(),
|
||||
Some("copy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_selection_falls_back_only_when_the_live_match_is_unique() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("stale");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember stale selection");
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &["other".to_string()])
|
||||
.expect("reconcile unique replacement")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
assert_eq!(
|
||||
selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read repaired selection")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
|
||||
assert!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &[])
|
||||
.expect("clear unmatched selection")
|
||||
.is_none()
|
||||
);
|
||||
assert!(selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read cleared selection")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,17 +200,6 @@ pub(super) fn create_provider_sync_backup(
|
||||
existed: path.exists(),
|
||||
});
|
||||
}
|
||||
for name in [
|
||||
"config.toml",
|
||||
".codex-global-state.json",
|
||||
".codex-global-state.json.bak",
|
||||
] {
|
||||
snapshots.push(copy_file_to_backup(
|
||||
codex_dir,
|
||||
&backup_dir,
|
||||
&codex_dir.join(name),
|
||||
)?);
|
||||
}
|
||||
for path in changed_rollouts {
|
||||
snapshots.push(copy_file_to_backup(codex_dir, &backup_dir, path)?);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
use super::app_server::delete_sessions_via_codex_app_server;
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
scan_rollouts, split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment,
|
||||
SqliteDiscovery, SqliteThreadIndexState,
|
||||
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
|
||||
split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
|
||||
SqliteThreadIndexState,
|
||||
};
|
||||
use super::sync::{acquire_session_maintenance_lock, session_sync_status_with_discovery};
|
||||
use super::types::SessionSyncStatus;
|
||||
@@ -313,7 +314,9 @@ fn active_session_storage_snapshot(
|
||||
|
||||
if cols.contains("model_provider") {
|
||||
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
|
||||
let query = format!("SELECT id, model_provider, {cwd_col} FROM threads");
|
||||
let archived_col = sql_select_column(&cols, "archived", "0");
|
||||
let query =
|
||||
format!("SELECT id, model_provider, {cwd_col}, {archived_col} FROM threads");
|
||||
let mut mismatch_stmt = conn
|
||||
.prepare(&query)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
@@ -323,11 +326,12 @@ fn active_session_storage_snapshot(
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
for row in mismatches {
|
||||
let (id, provider, cwd) =
|
||||
let (id, provider, cwd, archived) =
|
||||
row.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if sqlite_thread_needs_alignment(
|
||||
&rollouts,
|
||||
@@ -337,6 +341,7 @@ fn active_session_storage_snapshot(
|
||||
provider: provider.as_deref(),
|
||||
cwd: cwd.as_deref(),
|
||||
cwd_column: cols.contains("cwd"),
|
||||
archived: archived != 0,
|
||||
},
|
||||
) {
|
||||
snapshot.mismatched_ids.insert(id);
|
||||
@@ -387,30 +392,6 @@ fn collect_rollout_storage_paths(root: &Path, out: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rollout_filename_matches_id(path: &Path, id: &str) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.ends_with(&format!("-{id}.jsonl")) || name.ends_with(&format!("-{id}.jsonl.zst"))
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_rollout_storage_roots(codex_dir: &Path) -> Vec<PathBuf> {
|
||||
[
|
||||
codex_dir.join("sessions"),
|
||||
codex_dir.join("archived_sessions"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|root| root.canonicalize().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_canonical_rollout_storage_path(codex_dir: &Path, path: &Path) -> bool {
|
||||
canonical_rollout_storage_roots(codex_dir)
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
fn canonical_rollout_path(codex_dir: &Path, value: &str, id: &str) -> Result<Option<PathBuf>> {
|
||||
let raw = PathBuf::from(value.trim());
|
||||
let path = if raw.is_absolute() {
|
||||
@@ -932,6 +913,11 @@ pub(crate) fn delete_codex_sessions_inner(
|
||||
.intersection(&storage_before.subagent_ids)
|
||||
.count();
|
||||
let deleted_top_level = deleted_active_ids.len().saturating_sub(deleted_subagents);
|
||||
let deleted_mismatched_sessions = fallback
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|item| item.needs_sync && deleted_active_ids.contains(&item.id))
|
||||
.count();
|
||||
fallback
|
||||
.sessions
|
||||
.retain(|item| !counts.deleted_ids.contains(&item.id));
|
||||
@@ -944,7 +930,10 @@ pub(crate) fn delete_codex_sessions_inner(
|
||||
fallback.mismatched_threads = fallback
|
||||
.mismatched_threads
|
||||
.saturating_sub(deleted_mismatched);
|
||||
fallback.needs_sync = fallback.mismatched_threads > 0;
|
||||
fallback.mismatched_sessions = fallback
|
||||
.mismatched_sessions
|
||||
.saturating_sub(deleted_mismatched_sessions);
|
||||
fallback.needs_sync = fallback.mismatched_sessions > 0;
|
||||
fallback.warnings.push(message);
|
||||
fallback
|
||||
}
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err, json_err, write_text};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct GlobalStateWrite {
|
||||
pub(super) path: std::path::PathBuf,
|
||||
pub(super) original_bytes: Option<Vec<u8>>,
|
||||
pub(super) written_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -26,313 +12,3 @@ pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn load_global_state(path: &Path) -> Result<Map<String, Value>> {
|
||||
if !path.exists() {
|
||||
return Ok(Map::new());
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
|
||||
let value = serde_json::from_str::<Value>(&text).map_err(|error| json_err(path, error))?;
|
||||
Ok(value.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn projectless_thread_ids(path: &Path) -> Result<HashSet<String>> {
|
||||
let state = load_global_state(path)?;
|
||||
Ok(state
|
||||
.get("projectless-thread-ids")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn normalized_path_array(value: &Value) -> Vec<String> {
|
||||
if let Some(items) = value.as_array() {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter_map(normalize_workspace_path)
|
||||
.collect()
|
||||
} else {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(normalize_workspace_path)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_workspace_paths(paths: Vec<String>) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
paths
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
seen.insert(
|
||||
path.replace('/', r"\")
|
||||
.trim_end_matches('\\')
|
||||
.to_ascii_lowercase(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_global_state(state: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut next = Map::new();
|
||||
for key in ["electron-saved-workspace-roots", "project-order"] {
|
||||
if let Some(value) = state.get(key) {
|
||||
next.insert(
|
||||
key.to_string(),
|
||||
json!(dedupe_workspace_paths(normalized_path_array(value))),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(value) = state.get("active-workspace-roots") {
|
||||
let normalized = dedupe_workspace_paths(normalized_path_array(value));
|
||||
let next_value = if value.is_array() {
|
||||
json!(normalized)
|
||||
} else if let Some(first) = normalized.first() {
|
||||
json!(first)
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
next.insert("active-workspace-roots".to_string(), next_value);
|
||||
}
|
||||
if let Some(labels) = state
|
||||
.get("electron-workspace-root-labels")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let mut normalized = Map::new();
|
||||
for (path, value) in labels {
|
||||
normalized.insert(
|
||||
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
|
||||
value.clone(),
|
||||
);
|
||||
}
|
||||
next.insert(
|
||||
"electron-workspace-root-labels".to_string(),
|
||||
Value::Object(normalized),
|
||||
);
|
||||
}
|
||||
if let Some(open_targets) = state
|
||||
.get("open-in-target-preferences")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let mut normalized = open_targets.clone();
|
||||
if let Some(per_path) = open_targets.get("perPath").and_then(Value::as_object) {
|
||||
let mut normalized_per_path = Map::new();
|
||||
for (path, value) in per_path {
|
||||
normalized_per_path.insert(
|
||||
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
|
||||
value.clone(),
|
||||
);
|
||||
}
|
||||
normalized.insert("perPath".to_string(), Value::Object(normalized_per_path));
|
||||
}
|
||||
next.insert(
|
||||
"open-in-target-preferences".to_string(),
|
||||
Value::Object(normalized),
|
||||
);
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
pub(super) fn count_global_state_updates(path: &Path) -> Result<usize> {
|
||||
let state = load_global_state(path)?;
|
||||
let next = normalized_global_state(&state);
|
||||
Ok(next
|
||||
.iter()
|
||||
.filter(|(key, value)| state.get(*key) != Some(*value))
|
||||
.count())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_global_state_update(path: &Path) -> Result<usize> {
|
||||
apply_global_state_update_with_journal(path, &mut Vec::new(), &mut || Ok(()))
|
||||
}
|
||||
|
||||
fn read_optional_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_global_state_update_with_journal<F>(
|
||||
path: &Path,
|
||||
writes: &mut Vec<GlobalStateWrite>,
|
||||
after_main_write: &mut F,
|
||||
) -> Result<usize>
|
||||
where
|
||||
F: FnMut() -> Result<()>,
|
||||
{
|
||||
let original_bytes = read_optional_bytes(path)?;
|
||||
let mut state = match &original_bytes {
|
||||
Some(bytes) => serde_json::from_slice::<Value>(bytes)
|
||||
.map_err(|error| json_err(path, error))?
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
None => Map::new(),
|
||||
};
|
||||
let next = normalized_global_state(&state);
|
||||
let updated = next
|
||||
.iter()
|
||||
.filter(|(key, value)| state.get(*key) != Some(*value))
|
||||
.count();
|
||||
if updated > 0 {
|
||||
for (key, value) in next {
|
||||
state.insert(key, value);
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&Value::Object(state))
|
||||
.map_err(|error| json_err(path, error))?;
|
||||
let written_bytes = text.as_bytes().to_vec();
|
||||
if read_optional_bytes(path)? != original_bytes {
|
||||
return Err(CodexxError::Config(
|
||||
"全局状态已发生变化,请重试。".to_string(),
|
||||
));
|
||||
}
|
||||
write_text(path, &text)?;
|
||||
writes.push(GlobalStateWrite {
|
||||
path: path.to_path_buf(),
|
||||
original_bytes,
|
||||
written_bytes: written_bytes.clone(),
|
||||
});
|
||||
after_main_write()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let backup = parent.join(".codex-global-state.json.bak");
|
||||
let original_bytes = read_optional_bytes(&backup)?;
|
||||
if read_optional_bytes(&backup)? != original_bytes {
|
||||
return Err(CodexxError::Config(
|
||||
"全局状态已发生变化,请重试。".to_string(),
|
||||
));
|
||||
}
|
||||
write_text(&backup, &text)?;
|
||||
writes.push(GlobalStateWrite {
|
||||
path: backup,
|
||||
original_bytes,
|
||||
written_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(super) fn restore_global_write(write: &GlobalStateWrite) -> Result<()> {
|
||||
match fs::read(&write.path) {
|
||||
Ok(current) if current == write.written_bytes => {}
|
||||
Ok(_) => {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"全局状态已发生变化,无法安全恢复: {}",
|
||||
write.path.display()
|
||||
)));
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::NotFound && write.original_bytes.is_none() =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(io_err(&write.path, error)),
|
||||
}
|
||||
|
||||
if let Some(original) = &write.original_bytes {
|
||||
atomic_write(&write.path, original)
|
||||
} else {
|
||||
fs::remove_file(&write.path).map_err(|error| io_err(&write.path, error))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-global-state-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_state_update_keeps_unknown_fields_and_matches_backup() {
|
||||
let dir = temp_dir("update");
|
||||
let path = dir.join(".codex-global-state.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"electron-saved-workspace-roots": "/tmp/project",
|
||||
"unrelated-setting": { "enabled": true }
|
||||
}"#,
|
||||
)
|
||||
.expect("write original global state");
|
||||
|
||||
assert_eq!(
|
||||
apply_global_state_update(&path).expect("update global state"),
|
||||
1
|
||||
);
|
||||
|
||||
let main_text = fs::read_to_string(&path).expect("read global state");
|
||||
let backup_path = dir.join(".codex-global-state.json.bak");
|
||||
let backup_text = fs::read_to_string(&backup_path).expect("read global state backup");
|
||||
assert_eq!(main_text, backup_text);
|
||||
let state: Value = serde_json::from_str(&main_text).expect("parse global state");
|
||||
assert_eq!(
|
||||
state.get("electron-saved-workspace-roots"),
|
||||
Some(&json!(["/tmp/project"]))
|
||||
);
|
||||
assert_eq!(
|
||||
state.get("unrelated-setting"),
|
||||
Some(&json!({ "enabled": true }))
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_distinct_existing_global_main_and_backup() {
|
||||
let dir = temp_dir("distinct-snapshots");
|
||||
let main = dir.join(".codex-global-state.json");
|
||||
let backup = dir.join(".codex-global-state.json.bak");
|
||||
let original_main = br#"{"source":"main","value":1}"#;
|
||||
let original_backup = br#"{"source":"backup","value":2}"#;
|
||||
let written_text = r#"{"source":"mutated"}"#.to_string();
|
||||
fs::write(&main, original_main).expect("write original main");
|
||||
fs::write(&backup, original_backup).expect("write original backup");
|
||||
write_text(&main, &written_text).expect("mutate global main");
|
||||
write_text(&backup, &written_text).expect("mutate global backup");
|
||||
let writes = [
|
||||
GlobalStateWrite {
|
||||
path: main.clone(),
|
||||
original_bytes: Some(original_main.to_vec()),
|
||||
written_bytes: written_text.as_bytes().to_vec(),
|
||||
},
|
||||
GlobalStateWrite {
|
||||
path: backup.clone(),
|
||||
original_bytes: Some(original_backup.to_vec()),
|
||||
written_bytes: written_text.into_bytes(),
|
||||
},
|
||||
];
|
||||
|
||||
for write in writes.iter().rev() {
|
||||
restore_global_write(write).expect("restore global write");
|
||||
}
|
||||
assert_eq!(fs::read(&main).expect("read restored main"), original_main);
|
||||
assert_eq!(
|
||||
fs::read(&backup).expect("read restored backup"),
|
||||
original_backup
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod global_state;
|
||||
mod storage;
|
||||
@@ -14,11 +15,14 @@ pub(crate) use backup::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
|
||||
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
|
||||
pub(crate) use storage::sqlite_candidate_paths;
|
||||
#[cfg(test)]
|
||||
pub(crate) use storage::{
|
||||
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
|
||||
scan_sqlite, sqlite_session_db_paths,
|
||||
};
|
||||
pub(crate) use storage::{
|
||||
session_project_title, session_titles_by_id, source_value_is_subagent, sqlite_candidate_paths,
|
||||
usage_thread_identities, UsageThreadIdentity,
|
||||
};
|
||||
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
|
||||
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
use super::global_state::{
|
||||
apply_global_state_update_with_journal, restore_global_write, GlobalStateWrite,
|
||||
use super::catalog::{
|
||||
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
|
||||
restore_catalog_updates, CatalogRepairThread,
|
||||
};
|
||||
use super::storage::{apply_session_changes, restore_session_changes};
|
||||
use super::types::{RolloutScan, SessionFileChange};
|
||||
@@ -7,7 +8,7 @@ use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -15,16 +16,18 @@ use std::time::Duration;
|
||||
pub(super) struct SqliteUpdateCounts {
|
||||
provider_rows: usize,
|
||||
cwd_rows: usize,
|
||||
catalog_insert_rows: usize,
|
||||
}
|
||||
|
||||
impl SqliteUpdateCounts {
|
||||
pub(super) fn total(&self) -> usize {
|
||||
self.provider_rows + self.cwd_rows
|
||||
self.provider_rows + self.cwd_rows + self.catalog_insert_rows
|
||||
}
|
||||
|
||||
fn add(&mut self, other: Self) {
|
||||
self.provider_rows += other.provider_rows;
|
||||
self.cwd_rows += other.cwd_rows;
|
||||
self.catalog_insert_rows += other.catalog_insert_rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +35,6 @@ impl SqliteUpdateCounts {
|
||||
pub(super) struct MutationJournal {
|
||||
applied_rollouts: Vec<SessionFileChange>,
|
||||
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
|
||||
global_writes: Vec<GlobalStateWrite>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -45,7 +47,8 @@ pub(super) struct PendingSqliteUpdate {
|
||||
path: PathBuf,
|
||||
conn: Connection,
|
||||
observer: Connection,
|
||||
columns: HashSet<String>,
|
||||
thread_columns: HashSet<String>,
|
||||
catalog_columns: HashSet<String>,
|
||||
counts: SqliteUpdateCounts,
|
||||
transaction_open: bool,
|
||||
}
|
||||
@@ -80,7 +83,8 @@ fn create_sqlite_rollback_table(conn: &Connection) -> Result<()> {
|
||||
cwd_changed INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
create_catalog_rollback_tables(conn)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<PendingSqliteUpdate>> {
|
||||
@@ -107,11 +111,17 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
|
||||
})?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !sqlite_has_table(&conn, "threads")? {
|
||||
continue;
|
||||
}
|
||||
let columns = table_column_set(&conn, "threads")?;
|
||||
if !columns.contains("id") || !columns.contains("model_provider") {
|
||||
let thread_columns = if sqlite_has_table(&conn, "threads")? {
|
||||
table_column_set(&conn, "threads")?
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
let catalog_columns = catalog_columns(&conn)?;
|
||||
let supports_thread_updates =
|
||||
thread_columns.contains("id") && thread_columns.contains("model_provider");
|
||||
let supports_catalog_updates =
|
||||
catalog_columns.contains("thread_id") && catalog_columns.contains("model_provider");
|
||||
if !supports_thread_updates && !supports_catalog_updates {
|
||||
continue;
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
@@ -147,7 +157,8 @@ pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<Pen
|
||||
path: identity,
|
||||
conn,
|
||||
observer,
|
||||
columns,
|
||||
thread_columns,
|
||||
catalog_columns,
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
});
|
||||
@@ -165,53 +176,86 @@ fn apply_sqlite_updates(
|
||||
pending: &mut [PendingSqliteUpdate],
|
||||
rollouts: &RolloutScan,
|
||||
target_provider: &str,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
|
||||
sorted_thread_ids.sort();
|
||||
for update in pending.iter_mut() {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
let archived_filter = if update.thread_columns.contains("archived") {
|
||||
" AND COALESCE(archived, 0) = 0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let snapshot_sql = format!(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE COALESCE(model_provider, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows = update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
|
||||
if update.columns.contains("id") && update.columns.contains("cwd") {
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1"
|
||||
);
|
||||
let update_sql = format!(
|
||||
"UPDATE threads SET model_provider = ?1 \
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for thread_id in &sorted_thread_ids {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, cwd, cwd_changed)
|
||||
SELECT id, cwd, 1 FROM threads
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
cwd = excluded.cwd,
|
||||
cwd_changed = 1",
|
||||
(cwd, thread_id),
|
||||
)
|
||||
.execute(&snapshot_sql, (target_provider, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.cwd_rows += update
|
||||
update.counts.provider_rows += update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
|
||||
(cwd, thread_id),
|
||||
)
|
||||
.execute(&update_sql, (target_provider, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
let archived_filter = if update.thread_columns.contains("archived") {
|
||||
" AND COALESCE(archived, 0) = 0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let snapshot_sql = format!(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, cwd, cwd_changed)
|
||||
SELECT id, cwd, 1 FROM threads
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
cwd = excluded.cwd,
|
||||
cwd_changed = 1"
|
||||
);
|
||||
let update_sql = format!(
|
||||
"UPDATE threads SET cwd = ?1 \
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
if !syncable_thread_ids.contains(thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
.conn
|
||||
.execute(&snapshot_sql, (cwd, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.cwd_rows += update
|
||||
.conn
|
||||
.execute(&update_sql, (cwd, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let catalog_counts = apply_catalog_updates(
|
||||
&update.conn,
|
||||
&update.catalog_columns,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
syncable_thread_ids,
|
||||
)?;
|
||||
update.counts.provider_rows += catalog_counts.provider_rows;
|
||||
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -273,18 +317,23 @@ fn restore_sqlite_update(
|
||||
)));
|
||||
}
|
||||
|
||||
let mut statements = vec![
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
];
|
||||
if update.columns.contains("cwd") {
|
||||
restore_catalog_updates(&update.conn, &update.catalog_columns)?;
|
||||
let mut statements = Vec::new();
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
);
|
||||
}
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("cwd") {
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET cwd = (
|
||||
@@ -321,11 +370,6 @@ pub(super) fn rollback_mutation(
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
for write in journal.global_writes.iter().rev() {
|
||||
if let Err(error) = restore_global_write(write) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
for attempt in journal.sqlite_restore_attempts.iter().rev() {
|
||||
let Some(update) = pending_sqlite
|
||||
.iter_mut()
|
||||
@@ -357,8 +401,8 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
BeforeSqliteLock,
|
||||
AfterSqliteCommit(usize),
|
||||
AfterGlobalMainWrite,
|
||||
}
|
||||
|
||||
pub(super) struct MutationResult {
|
||||
@@ -371,7 +415,8 @@ pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
target_provider: &str,
|
||||
global_state_path: &Path,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
journal: &mut MutationJournal,
|
||||
hook: &mut F,
|
||||
) -> Result<MutationResult>
|
||||
@@ -381,11 +426,12 @@ where
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
apply_sqlite_updates(pending_sqlite, rollouts, target_provider)?;
|
||||
apply_global_state_update_with_journal(
|
||||
global_state_path,
|
||||
&mut journal.global_writes,
|
||||
&mut || hook(MutationPoint::AfterGlobalMainWrite),
|
||||
apply_sqlite_updates(
|
||||
pending_sqlite,
|
||||
rollouts,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
syncable_thread_ids,
|
||||
)?;
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
|
||||
@@ -18,6 +18,8 @@ fn temp_dir(name: &str) -> PathBuf {
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
fs::write(path.join("config.toml"), "model_provider = \"custom\"\n")
|
||||
.expect("write shared provider config");
|
||||
path
|
||||
}
|
||||
|
||||
@@ -176,7 +178,8 @@ fn failed_sqlite_commit_rolls_back_without_snapshot_restore() {
|
||||
path: database.clone(),
|
||||
conn,
|
||||
observer,
|
||||
columns: HashSet::new(),
|
||||
thread_columns: HashSet::new(),
|
||||
catalog_columns: HashSet::new(),
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
}];
|
||||
@@ -332,53 +335,7 @@ fn sqlite_prepare_deduplicates_symlink_aliases() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_main_failpoint_restores_prior_mutations_without_touching_backup() {
|
||||
let codex_dir = temp_dir("global-main-failpoint");
|
||||
let id = "019f6000-0000-7000-8000-000000000408";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let original_rollout = write_rollout(&rollout, id);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
let global = codex_dir.join(".codex-global-state.json");
|
||||
let global_backup = codex_dir.join(".codex-global-state.json.bak");
|
||||
let original_global = br#"{"electron-saved-workspace-roots":"/tmp/project"}"#;
|
||||
fs::write(&global, original_global).expect("write original global state");
|
||||
assert!(!global_backup.exists());
|
||||
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
|point| match point {
|
||||
MutationPoint::AfterGlobalMainWrite => {
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read mutated rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
assert!(!global_backup.exists());
|
||||
Err(CodexxError::Config("主状态写入后注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect_err("fail after global main write");
|
||||
|
||||
assert_eq!(error.to_string(), "配置错误: 主状态写入后注入失败");
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(&rollout).expect("read restored rollout"),
|
||||
original_rollout
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&global).expect("read restored global"),
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_failure_restores_sqlite_jsonl_and_global_state() {
|
||||
fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
|
||||
let codex_dir = temp_dir("full-mutation-rollback");
|
||||
let id = "019f6000-0000-7000-8000-000000000411";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
@@ -440,10 +397,11 @@ fn injected_failure_restores_sqlite_jsonl_and_global_state() {
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read mutated rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
let main = fs::read_to_string(&global).expect("read mutated global state");
|
||||
let backup =
|
||||
fs::read_to_string(&global_backup).expect("read mutated global backup");
|
||||
assert_eq!(main, backup);
|
||||
assert_eq!(
|
||||
fs::read(&global).expect("read untouched global state"),
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
Err(CodexxError::Config("测试注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
@@ -478,10 +436,18 @@ fn injected_failure_restores_sqlite_jsonl_and_global_state() {
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
assert!(fs::read_dir(provider_sync_backup_root(&codex_dir))
|
||||
let retained_backup = fs::read_dir(provider_sync_backup_root(&codex_dir))
|
||||
.expect("read retained provider sync backup")
|
||||
.next()
|
||||
.is_some());
|
||||
.expect("retained provider sync backup")
|
||||
.expect("read retained provider sync backup entry")
|
||||
.path();
|
||||
let metadata = fs::read_to_string(retained_backup.join("metadata.json"))
|
||||
.expect("read retained provider sync metadata");
|
||||
assert!(!metadata.contains("config.toml"));
|
||||
assert!(!metadata.contains(".codex-global-state.json"));
|
||||
assert!(!retained_backup.join("config.toml").exists());
|
||||
assert!(!retained_backup.join(".codex-global-state.json").exists());
|
||||
|
||||
drop(wal_guard);
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -33,7 +33,10 @@ pub(crate) struct SessionSyncStatus {
|
||||
pub(crate) top_level_threads: usize,
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) mismatched_sessions: usize,
|
||||
pub(crate) needs_sync: bool,
|
||||
pub(crate) scan_complete: bool,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
pub(crate) backup_dir: Option<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) sessions: Vec<SessionPreview>,
|
||||
@@ -50,13 +53,17 @@ pub(crate) struct SessionSyncResult {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct RolloutScan {
|
||||
pub(crate) discovered_rollout_files: usize,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
pub(crate) mismatched_rollouts: usize,
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) cwd_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -74,5 +81,12 @@ pub(crate) struct SqliteScan {
|
||||
pub(crate) top_level_threads: usize,
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
pub(crate) syncable_thread_ids: HashSet<String>,
|
||||
pub(crate) archived_thread_ids: HashSet<String>,
|
||||
pub(crate) subagent_thread_ids: HashSet<String>,
|
||||
pub(crate) rollout_paths_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{ManagedMcpServer, SkillsMcpState};
|
||||
use crate::backups::create_backup;
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists};
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, apply_file_change, fail_with_file_rollback, read_file_snapshot,
|
||||
text_from_snapshot,
|
||||
};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use rusqlite::{
|
||||
params, Connection, OpenFlags, OptionalExtension, Transaction, TransactionBehavior,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use toml_edit::{value, Item, Table};
|
||||
|
||||
type CcSwitchMcpCandidate = (String, String, Value, bool);
|
||||
|
||||
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(s) = value.as_str() {
|
||||
return json!(s);
|
||||
@@ -134,8 +143,13 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
fn save_managed_mcp_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
config: &Value,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
@@ -156,6 +170,83 @@ pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_managed_mcp_on_connection(&conn, id, name, config, enabled)
|
||||
}
|
||||
|
||||
fn managed_mcp_on_connection(conn: &Connection, id: &str) -> Result<Option<(String, Value, bool)>> {
|
||||
conn.query_row(
|
||||
"SELECT name, server_config, enabled FROM managed_mcp_servers WHERE id = ?1 LIMIT 1",
|
||||
[id],
|
||||
|row| {
|
||||
let config_text: String = row.get(1)?;
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default())),
|
||||
row.get(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
|
||||
doc.get("mcp_servers")
|
||||
.and_then(|item| item.as_table())
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_table())
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
|
||||
let bytes = doc.to_string().into_bytes();
|
||||
if snapshot.is_none() && bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
transaction: Transaction<'_>,
|
||||
codex_dir: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
backup_action: &str,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<()>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let cfg = config_path(codex_dir);
|
||||
let mut changes = Vec::new();
|
||||
if before != after {
|
||||
create_backup(codex_dir, backup_action)?;
|
||||
before_apply(&cfg)?;
|
||||
changes.push(apply_file_change(&cfg, before, after)?);
|
||||
}
|
||||
|
||||
if let Err(error) = before_commit(&transaction) {
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
if let Err(error) = transaction
|
||||
.commit()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
{
|
||||
return fail_with_file_rollback(error, &changes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
|
||||
@@ -206,6 +297,7 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
|
||||
enabled: true,
|
||||
source: "config.toml".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
@@ -264,36 +356,76 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut imported = 0usize;
|
||||
let mut changed_config = false;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
if !imported_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let enabled = enabled_codex || live_enabled.contains(&id);
|
||||
save_managed_mcp(&id, &name, &config, enabled)?;
|
||||
if enabled_codex && !live_enabled.contains(&id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?
|
||||
.insert(&id, json_to_toml_item(&config));
|
||||
changed_config = true;
|
||||
}
|
||||
imported += 1;
|
||||
candidates.push((id, name, config, enabled_codex));
|
||||
}
|
||||
if changed_config {
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
codex_dir,
|
||||
imported_ids,
|
||||
candidates,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
}
|
||||
|
||||
fn import_ccswitch_mcp_candidates_with_hooks<BeforeApply, BeforeCommit>(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
candidates: Vec<CcSwitchMcpCandidate>,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<usize>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let mut staged_ids = HashSet::new();
|
||||
let candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
ensure_directory(codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(codex_dir)?;
|
||||
let cfg = config_path(codex_dir);
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = document_mcp_ids(&doc);
|
||||
|
||||
let mut app_conn = open_db()?;
|
||||
let transaction = app_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
for (id, name, config, enabled_codex) in &candidates {
|
||||
let enabled = *enabled_codex || live_enabled.contains(id);
|
||||
save_managed_mcp_on_connection(&transaction, id, name, config, enabled)?;
|
||||
if *enabled_codex && !live_enabled.contains(id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(id, json_to_toml_item(config));
|
||||
}
|
||||
}
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
codex_dir,
|
||||
before,
|
||||
after,
|
||||
"import-ccswitch-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
|
||||
let imported = candidates.len();
|
||||
imported_ids.extend(staged_ids);
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -355,6 +487,7 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
enabled: enabled_codex || live_enabled.contains(&id),
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
@@ -368,31 +501,287 @@ pub(crate) fn toggle_codex_mcp_inner(
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<SkillsMcpState> {
|
||||
toggle_codex_mcp_with_hooks(config_dir, id, enabled, |_| Ok(()), |_| Ok(()))
|
||||
}
|
||||
|
||||
fn toggle_codex_mcp_with_hooks<BeforeApply, BeforeCommit>(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
before_apply: BeforeApply,
|
||||
before_commit: BeforeCommit,
|
||||
) -> Result<SkillsMcpState>
|
||||
where
|
||||
BeforeApply: FnOnce(&Path) -> Result<()>,
|
||||
BeforeCommit: FnOnce(&Transaction<'_>) -> Result<()>,
|
||||
{
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _live_lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, before.as_deref())?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
|
||||
let mut conn = open_db()?;
|
||||
let transaction = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let stored = managed_mcp_on_connection(&transaction, &id)?;
|
||||
if enabled {
|
||||
let db = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.find(|(sid, _, _, _)| sid == &id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
|
||||
save_managed_mcp(&id, &db.1, &db.2, true)?;
|
||||
let (name, config, _) =
|
||||
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
|
||||
} else {
|
||||
if let Some(item) = doc
|
||||
let live_config = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|m| m.as_table())
|
||||
.and_then(|tbl| tbl.get(&id))
|
||||
{
|
||||
let config = toml_item_to_json(item);
|
||||
save_managed_mcp(&id, &id, &config, false)?;
|
||||
.map(toml_item_to_json);
|
||||
if let Some(config) = live_config {
|
||||
let name = stored
|
||||
.as_ref()
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
.unwrap_or(&id);
|
||||
save_managed_mcp_on_connection(&transaction, &id, name, &config, false)?;
|
||||
} else if let Some((name, config, _)) = stored {
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, false)?;
|
||||
}
|
||||
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
|
||||
tbl.remove(&id);
|
||||
}
|
||||
}
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
let after = document_bytes(before.as_deref(), &doc);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
&codex_dir,
|
||||
before,
|
||||
after,
|
||||
"toggle-mcp",
|
||||
before_apply,
|
||||
before_commit,
|
||||
)?;
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn test_case(name: &str) -> (PathBuf, String) {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-mcp-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create MCP test directory");
|
||||
(dir, format!("test-mcp-{name}-{suffix}"))
|
||||
}
|
||||
|
||||
fn database_error(error: rusqlite::Error) -> CodexxError {
|
||||
CodexxError::Database(error.to_string())
|
||||
}
|
||||
|
||||
fn remove_test_mcp(id: &str) {
|
||||
open_db()
|
||||
.expect("open test database")
|
||||
.execute("DELETE FROM managed_mcp_servers WHERE id = ?1", [id])
|
||||
.expect("remove test MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("success");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep this comment\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n\n[mcp_servers.existing]\ncommand = \"existing\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let enabled_state =
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), true)
|
||||
.expect("enable managed MCP");
|
||||
assert!(enabled_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.any(|server| server.id == id && server.enabled));
|
||||
let enabled_text = fs::read_to_string(&cfg).expect("read enabled config");
|
||||
let enabled_doc = enabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse enabled config");
|
||||
assert!(enabled_text.contains("# keep this comment"));
|
||||
assert_eq!(enabled_doc["features"]["js_repl"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
assert_eq!(
|
||||
enabled_doc["mcp_servers"][&id]["command"].as_str(),
|
||||
Some("managed")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(
|
||||
managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read enabled MCP")
|
||||
.expect("enabled MCP exists")
|
||||
.2
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
toggle_codex_mcp_inner(Some(codex_dir.display().to_string()), id.clone(), false)
|
||||
.expect("disable managed MCP");
|
||||
let disabled_text = fs::read_to_string(&cfg).expect("read disabled config");
|
||||
let disabled_doc = disabled_text
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.expect("parse disabled config");
|
||||
assert!(disabled_text.contains("# keep this comment"));
|
||||
assert!(disabled_doc["mcp_servers"].get(&id).is_none());
|
||||
assert_eq!(
|
||||
disabled_doc["mcp_servers"]["existing"]["command"].as_str(),
|
||||
Some("existing")
|
||||
);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (name, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read disabled MCP")
|
||||
.expect("disabled MCP exists");
|
||||
assert_eq!(name, "Managed");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rejects_a_stale_snapshot_without_changing_database_or_external_config() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("stale");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# original\nmodel = \"gpt-5.5\"\n";
|
||||
let external = b"# external update\nmodel = \"gpt-5.5\"\n\n[mcp_servers.external]\ncommand = \"external\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
let error = toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|path| {
|
||||
fs::write(path, external).map_err(|error| CodexxError::Config(error.to_string()))
|
||||
},
|
||||
|_| Ok(()),
|
||||
)
|
||||
.expect_err("stale MCP toggle must fail");
|
||||
|
||||
assert!(error.to_string().contains("已被其他程序修改"));
|
||||
assert_eq!(fs::read(&cfg).expect("read external config"), external);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, config, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert_eq!(config["command"].as_str(), Some("managed"));
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_rolls_back_config_when_database_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("commit");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# keep exact bytes\nmodel = \"gpt-5.5\"\n\n[features]\njs_repl = false\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
save_managed_mcp(&id, "Managed", &json!({ "command": "managed" }), false)
|
||||
.expect("seed managed MCP");
|
||||
|
||||
toggle_codex_mcp_with_hooks(
|
||||
Some(codex_dir.display().to_string()),
|
||||
id.clone(),
|
||||
true,
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the toggle");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
let conn = open_db().expect("open test database");
|
||||
let (_, _, enabled) = managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read managed MCP")
|
||||
.expect("managed MCP still exists");
|
||||
assert!(!enabled);
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
remove_test_mcp(&id);
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_import_rolls_back_file_database_and_ids_when_commit_fails() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let (codex_dir, id) = test_case("import-commit");
|
||||
let second_id = format!("{id}-second");
|
||||
let cfg = config_path(&codex_dir);
|
||||
let original = b"# import baseline\nmodel = \"gpt-5.5\"\n";
|
||||
fs::write(&cfg, original).expect("seed original config");
|
||||
let mut imported_ids = HashSet::new();
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
&codex_dir,
|
||||
&mut imported_ids,
|
||||
vec![
|
||||
(
|
||||
id.clone(),
|
||||
"Imported".to_string(),
|
||||
json!({ "command": "imported" }),
|
||||
true,
|
||||
),
|
||||
(
|
||||
second_id.clone(),
|
||||
"Imported second".to_string(),
|
||||
json!({ "command": "imported-second" }),
|
||||
true,
|
||||
),
|
||||
],
|
||||
|_| Ok(()),
|
||||
|transaction| {
|
||||
transaction
|
||||
.execute_batch("ROLLBACK")
|
||||
.map_err(database_error)
|
||||
},
|
||||
)
|
||||
.expect_err("database commit failure must fail the import");
|
||||
|
||||
assert_eq!(fs::read(&cfg).expect("read rolled-back config"), original);
|
||||
assert!(imported_ids.is_empty());
|
||||
let conn = open_db().expect("open test database");
|
||||
assert!(managed_mcp_on_connection(&conn, &id)
|
||||
.expect("read imported MCP")
|
||||
.is_none());
|
||||
assert!(managed_mcp_on_connection(&conn, &second_id)
|
||||
.expect("read second imported MCP")
|
||||
.is_none());
|
||||
drop(conn);
|
||||
assert!(codex_dir.join(".codexx-test-backups").is_dir());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove MCP test directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +18,74 @@ pub(crate) use types::ManagedSkill;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::paths::home_dir;
|
||||
use crate::paths::{home_dir, normalized_path_scope};
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::{now_rfc3339, open_db};
|
||||
use mcp::{
|
||||
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
|
||||
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const SKILLS_MCP_NOTE_MAX_CHARS: usize = 1000;
|
||||
|
||||
fn normalized_note_item_kind(item_kind: &str) -> Result<&'static str> {
|
||||
match item_kind.trim().to_ascii_lowercase().as_str() {
|
||||
"skill" => Ok("skill"),
|
||||
"mcp" => Ok("mcp"),
|
||||
_ => Err(crate::error::CodexxError::Config(
|
||||
"备注类型必须是 skill 或 mcp".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn skills_mcp_notes(codex_dir: &Path) -> Result<HashMap<(String, String), String>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT item_kind, item_id, note
|
||||
FROM skills_mcp_notes
|
||||
WHERE codex_dir = ?1
|
||||
ORDER BY item_kind ASC, item_id ASC",
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([normalized_path_scope(codex_dir)], |row| {
|
||||
Ok((
|
||||
(row.get::<_, String>(0)?, row.get::<_, String>(1)?),
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
let mut notes = HashMap::new();
|
||||
for row in rows {
|
||||
let (key, note) =
|
||||
row.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
notes.insert(key, note);
|
||||
}
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
fn attach_skills_mcp_notes(
|
||||
codex_dir: &Path,
|
||||
skills: &mut [types::ManagedSkill],
|
||||
mcp_servers: &mut [ManagedMcpServer],
|
||||
) -> Result<()> {
|
||||
let notes = skills_mcp_notes(codex_dir)?;
|
||||
for skill in skills {
|
||||
skill.note = notes.get(&("skill".to_string(), skill.id.clone())).cloned();
|
||||
}
|
||||
for server in mcp_servers {
|
||||
server.note = notes.get(&("mcp".to_string(), server.id.clone())).cloned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extend_unmanaged_mcp_candidates(
|
||||
output: &mut Vec<ManagedMcpServer>,
|
||||
@@ -82,11 +139,13 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
enabled,
|
||||
source: "Codex-X".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
}
|
||||
attach_skills_mcp_notes(&codex_dir, &mut skills, &mut mcp_servers)?;
|
||||
sort_managed_mcp_servers(&mut mcp_servers);
|
||||
sort_managed_skills(&mut skills);
|
||||
Ok(SkillsMcpState {
|
||||
@@ -99,6 +158,63 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn save_skills_mcp_note_inner(
|
||||
config_dir: Option<String>,
|
||||
item_kind: String,
|
||||
id: String,
|
||||
note: String,
|
||||
) -> Result<SkillsMcpState> {
|
||||
let item_kind = normalized_note_item_kind(&item_kind)?;
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::error::CodexxError::Config(
|
||||
"备注对象 ID 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let id = id.as_str();
|
||||
let note = note.trim();
|
||||
if note.chars().count() > SKILLS_MCP_NOTE_MAX_CHARS {
|
||||
return Err(crate::error::CodexxError::Config(format!(
|
||||
"备注不能超过 {SKILLS_MCP_NOTE_MAX_CHARS} 个字符"
|
||||
)));
|
||||
}
|
||||
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let codex_dir_scope = normalized_path_scope(&codex_dir);
|
||||
let current = build_skills_mcp_state_inner(config_dir.clone())?;
|
||||
let exists = match item_kind {
|
||||
"skill" => current.skills.iter().any(|skill| skill.id == id),
|
||||
"mcp" => current.mcp_servers.iter().any(|server| server.id == id),
|
||||
_ => false,
|
||||
};
|
||||
if !exists {
|
||||
return Err(crate::error::CodexxError::Config(format!(
|
||||
"未找到要备注的 {item_kind}: {id}"
|
||||
)));
|
||||
}
|
||||
|
||||
let conn = open_db()?;
|
||||
if note.is_empty() {
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes
|
||||
WHERE codex_dir = ?1 AND item_kind = ?2 AND item_id = ?3",
|
||||
params![codex_dir_scope, item_kind, id],
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO skills_mcp_notes
|
||||
(codex_dir, item_kind, item_id, note, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(codex_dir, item_kind, item_id) DO UPDATE SET
|
||||
note = excluded.note,
|
||||
updated_at = excluded.updated_at",
|
||||
params![codex_dir_scope, item_kind, id, note, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| crate::error::CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn import_existing_skills_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
@@ -223,6 +339,7 @@ mod tests {
|
||||
enabled: true,
|
||||
source: source.to_string(),
|
||||
summary: id.to_string(),
|
||||
note: None,
|
||||
command: Some(id.to_string()),
|
||||
url: None,
|
||||
config_json: json!({ "command": id }),
|
||||
@@ -271,4 +388,161 @@ mod tests {
|
||||
);
|
||||
assert!(second_preview.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_notes_are_isolated_by_kind_and_codex_home_and_empty_text_clears_them() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let item_id = format!("shared-note-item-{}", std::process::id());
|
||||
let spaced_id = " spaced-mcp ";
|
||||
let codex_dir =
|
||||
std::env::temp_dir().join(format!("codex-x-note-state-{}", std::process::id()));
|
||||
let other_codex_dir =
|
||||
std::env::temp_dir().join(format!("codex-x-note-state-other-{}", std::process::id()));
|
||||
let prepare_codex_dir = |dir: &Path| {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
let skill_dir = dir.join("skills").join(&item_id);
|
||||
fs::create_dir_all(&skill_dir).expect("create test skill directory");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: {item_id}\ndescription: test\n---\n"),
|
||||
)
|
||||
.expect("write test skill");
|
||||
fs::write(
|
||||
dir.join("config.toml"),
|
||||
format!(
|
||||
"[mcp_servers.{item_id}]\ncommand = \"test-server\"\n\
|
||||
[mcp_servers.\"{spaced_id}\"]\ncommand = \"spaced-server\"\n"
|
||||
),
|
||||
)
|
||||
.expect("write test MCP config");
|
||||
};
|
||||
prepare_codex_dir(&codex_dir);
|
||||
prepare_codex_dir(&other_codex_dir);
|
||||
|
||||
let conn = open_db().expect("open app database");
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
|
||||
params![&item_id, spaced_id],
|
||||
)
|
||||
.expect("clear stale test notes");
|
||||
drop(conn);
|
||||
let config_dir = Some(codex_dir.display().to_string());
|
||||
|
||||
save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"skill".to_string(),
|
||||
item_id.clone(),
|
||||
" 我的 Skill 备注 ".to_string(),
|
||||
)
|
||||
.expect("save skill note");
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"MCP note".to_string(),
|
||||
)
|
||||
.expect("save MCP note");
|
||||
assert_eq!(
|
||||
state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == item_id)
|
||||
.and_then(|skill| skill.note.as_deref()),
|
||||
Some("我的 Skill 备注")
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"mcp".to_string(),
|
||||
spaced_id.to_string(),
|
||||
"Spaced ID note".to_string(),
|
||||
)
|
||||
.expect("save note without normalizing the MCP ID");
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == spaced_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("Spaced ID note")
|
||||
);
|
||||
|
||||
let other_config_dir = Some(other_codex_dir.display().to_string());
|
||||
let other_state = build_skills_mcp_state_inner(other_config_dir.clone())
|
||||
.expect("load other CODEX_HOME state");
|
||||
assert!(other_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.is_some_and(|server| server.note.is_none()));
|
||||
save_skills_mcp_note_inner(
|
||||
other_config_dir,
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"Other MCP note".to_string(),
|
||||
)
|
||||
.expect("save note for other CODEX_HOME");
|
||||
let original_state = build_skills_mcp_state_inner(config_dir.clone())
|
||||
.expect("reload original CODEX_HOME state");
|
||||
assert_eq!(
|
||||
original_state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
|
||||
let state = save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"skill".to_string(),
|
||||
item_id.clone(),
|
||||
" ".to_string(),
|
||||
)
|
||||
.expect("clear skill note");
|
||||
assert!(state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == item_id)
|
||||
.is_some_and(|skill| skill.note.is_none()));
|
||||
assert_eq!(
|
||||
state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == item_id)
|
||||
.and_then(|server| server.note.as_deref()),
|
||||
Some("MCP note")
|
||||
);
|
||||
assert!(save_skills_mcp_note_inner(
|
||||
config_dir.clone(),
|
||||
"unknown".to_string(),
|
||||
item_id.clone(),
|
||||
"note".to_string(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(save_skills_mcp_note_inner(
|
||||
config_dir,
|
||||
"mcp".to_string(),
|
||||
item_id.clone(),
|
||||
"x".repeat(SKILLS_MCP_NOTE_MAX_CHARS + 1),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let conn = open_db().expect("reopen app database");
|
||||
conn.execute(
|
||||
"DELETE FROM skills_mcp_notes WHERE item_id IN (?1, ?2)",
|
||||
params![&item_id, spaced_id],
|
||||
)
|
||||
.expect("remove test notes");
|
||||
drop(conn);
|
||||
fs::remove_dir_all(&codex_dir).expect("remove test Codex directory");
|
||||
fs::remove_dir_all(&other_codex_dir).expect("remove other test Codex directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ pub(super) fn scan_skill_dir(
|
||||
id: id.clone(),
|
||||
name,
|
||||
description,
|
||||
note: None,
|
||||
directory,
|
||||
enabled,
|
||||
source: source.to_string(),
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) struct ManagedMcpServer {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) note: Option<String>,
|
||||
pub(crate) command: Option<String>,
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) config_json: Value,
|
||||
@@ -21,6 +22,7 @@ pub(crate) struct ManagedSkill {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) note: Option<String>,
|
||||
pub(crate) directory: String,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use crate::backups::{latest_backup, BackupEntry};
|
||||
use crate::backups::BackupEntry;
|
||||
#[cfg(test)]
|
||||
use crate::config_migration::migrate_legacy_prompt_config;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{io_err, json_err, parse_toml_document, read_to_string_if_exists};
|
||||
use crate::prompts::{
|
||||
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
|
||||
};
|
||||
use crate::providers::{list_saved_providers_inner, SavedProvider};
|
||||
use crate::providers::{
|
||||
clear_active_provider_on_connection, detected_live_custom_provider, document_is_official,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
official_auth_available, open_store, reconcile_active_provider_on_connection, SavedProvider,
|
||||
};
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -27,7 +32,7 @@ struct ProviderSummary {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CodexState {
|
||||
codex_dir: String,
|
||||
pub(crate) codex_dir: String,
|
||||
config_path: String,
|
||||
auth_path: String,
|
||||
config_exists: bool,
|
||||
@@ -35,12 +40,14 @@ pub(crate) struct CodexState {
|
||||
official_auth_available: bool,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) model_provider: Option<String>,
|
||||
pub(crate) is_official_provider: bool,
|
||||
instruction_file: Option<String>,
|
||||
pub(crate) instruction_enabled: bool,
|
||||
pub(crate) instruction_injection_mode: Option<String>,
|
||||
pub(crate) instruction_template_key: Option<String>,
|
||||
agents_path: String,
|
||||
active_saved_provider_id: Option<String>,
|
||||
pub(crate) active_saved_provider_id: Option<String>,
|
||||
pub(crate) active_official_profile_id: Option<String>,
|
||||
providers: Vec<ProviderSummary>,
|
||||
pub(crate) config_text: String,
|
||||
auth_preview: Option<Value>,
|
||||
@@ -62,7 +69,13 @@ fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
|
||||
// Keep the UI responsive when another tool or an interrupted write leaves
|
||||
// auth.json malformed. Mutating flows validate or replace auth explicitly;
|
||||
// the read-only state path should still report the rest of the config.
|
||||
let mut value: Value = match serde_json::from_str(&text) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
for (key, val) in obj.iter_mut() {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
@@ -148,15 +161,15 @@ fn normalized_provider_toml_for_match(text: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn active_saved_provider_id_from_config(
|
||||
fn matching_saved_provider_ids_from_config(
|
||||
config_text: &str,
|
||||
providers: &[SavedProvider],
|
||||
) -> Option<String> {
|
||||
) -> Vec<String> {
|
||||
let live = normalized_provider_toml_for_match(config_text);
|
||||
if live.is_empty() {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
let matches = providers
|
||||
providers
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider
|
||||
@@ -164,18 +177,33 @@ pub(crate) fn active_saved_provider_id_from_config(
|
||||
.as_deref()
|
||||
.is_some_and(|toml| normalized_provider_toml_for_match(toml) == live)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(matches.len() == 1).then(|| matches[0].id.clone())
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_saved_provider_id_from_config(
|
||||
config_text: &str,
|
||||
providers: &[SavedProvider],
|
||||
) -> Option<String> {
|
||||
let matches = matching_saved_provider_ids_from_config(config_text, providers);
|
||||
(matches.len() == 1).then(|| matches[0].clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
migrate_legacy_prompt_config(&codex_dir)?;
|
||||
build_state_after_migration(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
let cfg = config_path(&codex_dir);
|
||||
let auth = auth_path(&codex_dir);
|
||||
migrate_legacy_prompt_config(&codex_dir)?;
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let model = string_value(&doc, "model");
|
||||
let model_provider = string_value(&doc, "model_provider");
|
||||
let is_official_provider = document_is_official(&doc);
|
||||
let instruction_file = string_value(&doc, "model_instructions_file");
|
||||
let model_template_key = instruction_file
|
||||
.as_deref()
|
||||
@@ -193,10 +221,17 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
};
|
||||
let instruction_enabled = instruction_template_key.is_some();
|
||||
let providers = extract_providers(&doc, model_provider.as_deref());
|
||||
let active_saved_provider_id = if model_provider.as_deref() == Some("openai") {
|
||||
let conn = open_store()?;
|
||||
let saved_providers = list_saved_providers_on_connection(&conn)?;
|
||||
let active_saved_provider_id = if is_official_provider {
|
||||
clear_active_provider_on_connection(&conn, &codex_dir)?;
|
||||
None
|
||||
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
|
||||
let candidates = matching_saved_provider_ids_for_live(&live, &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
} else {
|
||||
active_saved_provider_id_from_config(&text, &list_saved_providers_inner()?)
|
||||
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
};
|
||||
|
||||
Ok(CodexState {
|
||||
@@ -205,19 +240,26 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
auth_path: auth.display().to_string(),
|
||||
config_exists: cfg.exists(),
|
||||
auth_exists: auth.exists(),
|
||||
official_auth_available: auth_has_material(&auth)?,
|
||||
official_auth_available: official_auth_available(&codex_dir)?,
|
||||
model,
|
||||
model_provider,
|
||||
is_official_provider,
|
||||
instruction_file,
|
||||
instruction_enabled,
|
||||
instruction_injection_mode,
|
||||
instruction_template_key,
|
||||
agents_path: agents_path(&codex_dir).display().to_string(),
|
||||
active_saved_provider_id,
|
||||
active_official_profile_id: crate::providers::official_profiles::active_profile_id(
|
||||
&codex_dir,
|
||||
is_official_provider,
|
||||
)?,
|
||||
providers,
|
||||
config_text: text,
|
||||
auth_preview: redacted_auth_preview(&auth)?,
|
||||
auth_text: read_to_string_if_exists(&auth)?,
|
||||
last_backup: latest_backup()?,
|
||||
// Backup discovery can traverse years of history. Keep core state fast;
|
||||
// restore actions resolve history only when the user requests it.
|
||||
last_backup: None,
|
||||
})
|
||||
}
|
||||
|
||||
+2665
-129
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.16",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -366,6 +366,7 @@ export function StartupWizardDialog({
|
||||
value={configDir || diagnostics.codexDir}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder="~/.codex"
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -111,7 +111,6 @@ export function PromptCategoryManager({
|
||||
expand: (name: string) => `Expand ${name}`,
|
||||
collapse: (name: string) => `Collapse ${name}`,
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (categoryToDelete) {
|
||||
setCategoryToDelete(null);
|
||||
|
||||
+1200
-413
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
Code2,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
@@ -14,6 +16,7 @@ export type OverviewLanguage = "zh" | "en";
|
||||
|
||||
export type OverviewPageProps = {
|
||||
lang: OverviewLanguage;
|
||||
ready: boolean;
|
||||
model?: string | null;
|
||||
configDir: string;
|
||||
resolvedCodexDir: string;
|
||||
@@ -21,6 +24,7 @@ export type OverviewPageProps = {
|
||||
providerLabel?: string | null;
|
||||
instructionEnabled: boolean;
|
||||
authExists: boolean;
|
||||
officialAuthAvailable: boolean;
|
||||
configPath?: string | null;
|
||||
modelProvider?: string | null;
|
||||
instructionPath?: string | null;
|
||||
@@ -69,6 +73,7 @@ function ConfigRow({ label, value }: ConfigRowProps) {
|
||||
|
||||
export function OverviewPage({
|
||||
lang,
|
||||
ready,
|
||||
model,
|
||||
configDir,
|
||||
resolvedCodexDir,
|
||||
@@ -76,6 +81,7 @@ export function OverviewPage({
|
||||
providerLabel,
|
||||
instructionEnabled,
|
||||
authExists,
|
||||
officialAuthAvailable,
|
||||
configPath,
|
||||
modelProvider,
|
||||
instructionPath,
|
||||
@@ -103,8 +109,9 @@ export function OverviewPage({
|
||||
instruction: "指令提示词",
|
||||
enabled: "已启用",
|
||||
disabled: "未启用",
|
||||
auth: "认证文件",
|
||||
authFile: "auth.json",
|
||||
auth: "认证状态",
|
||||
authFile: "auth.json 已找到",
|
||||
officialAuth: "官方认证已保存",
|
||||
noAuth: "未找到",
|
||||
updateFound: "发现新版本",
|
||||
updateAvailable: (version: string) => `Codex-X ${version} 已发布`,
|
||||
@@ -118,6 +125,8 @@ export function OverviewPage({
|
||||
model: "模型",
|
||||
providerName: "供应商标识",
|
||||
instructionFile: "指令文件",
|
||||
reading: "正在读取",
|
||||
readFailed: "读取失败",
|
||||
}
|
||||
: {
|
||||
eyebrow: "CODEX CONFIG MANAGER",
|
||||
@@ -134,8 +143,9 @@ export function OverviewPage({
|
||||
instruction: "Instructions",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
auth: "Auth file",
|
||||
authFile: "auth.json",
|
||||
auth: "Authentication",
|
||||
authFile: "auth.json found",
|
||||
officialAuth: "Official auth saved",
|
||||
noAuth: "Not found",
|
||||
updateFound: "New version available",
|
||||
updateAvailable: (version: string) => `Codex-X ${version} is available`,
|
||||
@@ -149,16 +159,29 @@ export function OverviewPage({
|
||||
model: "Model",
|
||||
providerName: "Provider",
|
||||
instructionFile: "Instruction file",
|
||||
reading: "Loading",
|
||||
readFailed: "Load failed",
|
||||
};
|
||||
|
||||
const displayModel = model?.trim() || text.modelMissing;
|
||||
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
|
||||
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
|
||||
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
|
||||
const displayConfigPath = configPath?.trim() || text.notConfigured;
|
||||
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
|
||||
const unresolvedStatus = loading ? text.reading : text.readFailed;
|
||||
const displayModel = ready ? (model?.trim() || text.modelMissing) : unresolvedStatus;
|
||||
const displayProvider = ready
|
||||
? (providerLabel?.trim() || modelProvider?.trim() || text.official)
|
||||
: unresolvedStatus;
|
||||
const displayModelProvider = ready ? (modelProvider?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayDirectory = ready
|
||||
? (resolvedCodexDir.trim() || configDir.trim() || text.notConfigured)
|
||||
: (configDir.trim() || unresolvedStatus);
|
||||
const displayConfigPath = ready ? (configPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const displayInstructionPath = ready ? (instructionPath?.trim() || text.notConfigured) : unresolvedStatus;
|
||||
const updateVersion = latestVersion?.trim() || "";
|
||||
const homeInputValue = configDir || resolvedCodexDir;
|
||||
const homeInputValue = configDir;
|
||||
const authAvailable = ready && (authExists || officialAuthAvailable);
|
||||
const authStatus = ready
|
||||
? authExists
|
||||
? text.authFile
|
||||
: officialAuthAvailable ? text.officialAuth : text.noAuth
|
||||
: unresolvedStatus;
|
||||
|
||||
return (
|
||||
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
|
||||
@@ -179,6 +202,7 @@ export function OverviewPage({
|
||||
value={homeInputValue}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder={text.directoryPlaceholder}
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
aria-label={text.codexHome}
|
||||
/>
|
||||
@@ -209,26 +233,26 @@ export function OverviewPage({
|
||||
<StatusCard
|
||||
icon={FileText}
|
||||
label={text.config}
|
||||
value={configExists ? text.found : text.missing}
|
||||
tone={configExists ? "success" : "muted"}
|
||||
value={ready ? (configExists ? text.found : text.missing) : unresolvedStatus}
|
||||
tone={ready && configExists ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Code2}
|
||||
label={text.provider}
|
||||
value={displayProvider}
|
||||
tone={modelProvider ? "active" : "muted"}
|
||||
tone={ready && modelProvider ? "active" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={Sparkles}
|
||||
label={text.instruction}
|
||||
value={instructionEnabled ? text.enabled : text.disabled}
|
||||
tone={instructionEnabled ? "success" : "muted"}
|
||||
value={ready ? (instructionEnabled ? text.enabled : text.disabled) : unresolvedStatus}
|
||||
tone={ready && instructionEnabled ? "success" : "muted"}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={KeyRound}
|
||||
label={text.auth}
|
||||
value={authExists ? text.authFile : text.noAuth}
|
||||
tone={authExists ? "success" : "muted"}
|
||||
value={authStatus}
|
||||
tone={authAvailable ? "success" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -238,9 +262,13 @@ export function OverviewPage({
|
||||
<p className="cx-overview-section-label">{text.liveStatus}</p>
|
||||
<h3>{text.currentConfig}</h3>
|
||||
</div>
|
||||
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
|
||||
{instructionEnabled ? text.on : text.off}
|
||||
<span className={`cx-overview-instruction-pill${ready && instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
|
||||
{!ready && loading
|
||||
? <Loader2 className="cx-overview-spin" size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: !ready
|
||||
? <CircleAlert size={14} strokeWidth={2} aria-hidden="true" />
|
||||
: <CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
{ready ? (instructionEnabled ? text.on : text.off) : unresolvedStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { PromptCategoryItem } from "../components/PromptCategoryManager";
|
||||
import { Button, IconButton, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import { usePromptCategories } from "../promptCategories";
|
||||
import type {
|
||||
BuiltinPromptDetail,
|
||||
BuiltinPromptStatus,
|
||||
InstructionMode,
|
||||
InstructionTemplate,
|
||||
@@ -53,6 +54,7 @@ export type PromptsPageProps = {
|
||||
instructionMode: InstructionMode;
|
||||
promptForm: SavedPrompt;
|
||||
editingPromptId: string | null;
|
||||
editingBuiltinPrompt: BuiltinPromptDetail | null;
|
||||
loading: boolean;
|
||||
actionBusy: string;
|
||||
promptSyncing: boolean;
|
||||
@@ -84,6 +86,7 @@ export type PromptsPageProps = {
|
||||
onEnableSavedPrompt: (id: string) => void | Promise<void>;
|
||||
onDisableExternalPrompt: MaybeAsyncAction;
|
||||
onEditPrompt: (prompt: SavedPrompt) => void;
|
||||
onEditBuiltinPrompt: (id: string) => void | Promise<void>;
|
||||
onDeletePrompt: (id: string) => void | Promise<void>;
|
||||
onPromptFormFieldChange: (field: PromptFormField, value: string) => void;
|
||||
onSavePrompt: MaybeAsyncAction;
|
||||
@@ -129,11 +132,17 @@ function getCopy(lang: Lang) {
|
||||
preservedDescription: "用户原有提示词,追加模式下继续生效。",
|
||||
existingPrompt: "用户原有指令提示词",
|
||||
edit: "编辑",
|
||||
editBuiltin: "查看/编辑本地模板",
|
||||
remove: "删除",
|
||||
formEyebrow: "CUSTOM PROMPT",
|
||||
addFormTitle: "添加提示词",
|
||||
editFormTitle: "编辑提示词",
|
||||
formDescription: "保存为 Markdown 文件,之后可在列表中单独启用。",
|
||||
builtinFormEyebrow: "LOCAL TEMPLATE",
|
||||
builtinFormTitle: "查看与编辑本地模板",
|
||||
builtinFormDescription: "名称和文件名跟随模板目录;内容修改在下次启用时生效,之后 GitHub 同步将永久跳过这份模板。",
|
||||
locallyModified: "本地已修改",
|
||||
saveLocalChanges: "保存本地修改",
|
||||
back: "返回",
|
||||
promptDetails: "提示词详情",
|
||||
promptName: "提示词名称",
|
||||
@@ -183,11 +192,17 @@ function getCopy(lang: Lang) {
|
||||
preservedDescription: "Existing user prompt preserved by append mode.",
|
||||
existingPrompt: "Existing user prompt",
|
||||
edit: "Edit",
|
||||
editBuiltin: "View or edit local template",
|
||||
remove: "Delete",
|
||||
formEyebrow: "CUSTOM PROMPT",
|
||||
addFormTitle: "Add prompt",
|
||||
editFormTitle: "Edit prompt",
|
||||
formDescription: "Save it as Markdown, then enable it separately from the list.",
|
||||
builtinFormEyebrow: "LOCAL TEMPLATE",
|
||||
builtinFormTitle: "View and edit local template",
|
||||
builtinFormDescription: "The name and filename follow the template catalog. Content changes apply on the next activation, then GitHub sync permanently skips this template.",
|
||||
locallyModified: "Locally modified",
|
||||
saveLocalChanges: "Save local changes",
|
||||
back: "Back",
|
||||
promptDetails: "Prompt details",
|
||||
promptName: "Prompt name",
|
||||
@@ -269,6 +284,7 @@ function PromptFormView({
|
||||
lang,
|
||||
promptForm,
|
||||
editingPromptId,
|
||||
editingBuiltinPrompt,
|
||||
loading,
|
||||
onInstructionModeChange,
|
||||
onPromptFormFieldChange,
|
||||
@@ -278,6 +294,7 @@ function PromptFormView({
|
||||
| "lang"
|
||||
| "promptForm"
|
||||
| "editingPromptId"
|
||||
| "editingBuiltinPrompt"
|
||||
| "loading"
|
||||
| "onInstructionModeChange"
|
||||
| "onPromptFormFieldChange"
|
||||
@@ -287,14 +304,15 @@ function PromptFormView({
|
||||
const titleId = useId();
|
||||
const filenameId = useId();
|
||||
const contentId = useId();
|
||||
const isBuiltin = Boolean(editingBuiltinPrompt);
|
||||
|
||||
return (
|
||||
<div className="cx-prompts-form-page">
|
||||
<header className="cx-prompts-header cx-prompts-form-header">
|
||||
<div className="cx-prompts-heading">
|
||||
<p><PencilLine size={14} aria-hidden="true" />{copy.formEyebrow}</p>
|
||||
<h2>{editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
|
||||
<span>{copy.formDescription}</span>
|
||||
<p><PencilLine size={14} aria-hidden="true" />{isBuiltin ? copy.builtinFormEyebrow : copy.formEyebrow}</p>
|
||||
<h2>{isBuiltin ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
|
||||
<span>{isBuiltin ? copy.builtinFormDescription : copy.formDescription}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -310,6 +328,9 @@ function PromptFormView({
|
||||
<div className="cx-prompts-form-panel-head">
|
||||
<FileText size={18} aria-hidden="true" />
|
||||
<h3 id={`${titleId}-panel`}>{copy.promptDetails}</h3>
|
||||
{editingBuiltinPrompt?.customized && (
|
||||
<StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="cx-prompts-form-grid">
|
||||
<label className="cx-prompts-field" htmlFor={titleId}>
|
||||
@@ -320,6 +341,7 @@ function PromptFormView({
|
||||
value={promptForm.title}
|
||||
onChange={(event) => onPromptFormFieldChange("title", event.currentTarget.value)}
|
||||
placeholder={copy.promptNamePlaceholder}
|
||||
readOnly={isBuiltin}
|
||||
disabled={loading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -332,6 +354,7 @@ function PromptFormView({
|
||||
value={promptForm.filename}
|
||||
onChange={(event) => onPromptFormFieldChange("filename", event.currentTarget.value)}
|
||||
placeholder={copy.filenamePlaceholder}
|
||||
readOnly={isBuiltin}
|
||||
disabled={loading}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
@@ -358,7 +381,7 @@ function PromptFormView({
|
||||
onClick={() => run(onSavePrompt)}
|
||||
disabled={loading}
|
||||
>
|
||||
{copy.save}
|
||||
{isBuiltin ? copy.saveLocalChanges : copy.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,6 +393,7 @@ export function PromptsPage({
|
||||
instructionMode,
|
||||
promptForm,
|
||||
editingPromptId,
|
||||
editingBuiltinPrompt,
|
||||
loading,
|
||||
actionBusy,
|
||||
promptSyncing,
|
||||
@@ -382,6 +406,7 @@ export function PromptsPage({
|
||||
activeInstructionTitle,
|
||||
activeInjectionMode,
|
||||
instructionTemplates,
|
||||
builtinPromptStatuses,
|
||||
activeBuiltinTemplateId,
|
||||
orphanedBuiltinPrompt,
|
||||
savedPrompts,
|
||||
@@ -400,6 +425,7 @@ export function PromptsPage({
|
||||
onEnableSavedPrompt,
|
||||
onDisableExternalPrompt,
|
||||
onEditPrompt,
|
||||
onEditBuiltinPrompt,
|
||||
onDeletePrompt,
|
||||
onPromptFormFieldChange,
|
||||
onSavePrompt,
|
||||
@@ -412,6 +438,10 @@ export function PromptsPage({
|
||||
const importBusy = actionBusy === "importPrompt";
|
||||
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
|
||||
const promptCategories = usePromptCategories(lang);
|
||||
const builtinStatusById = useMemo(
|
||||
() => new Map(builtinPromptStatuses.map((status) => [status.id, status])),
|
||||
[builtinPromptStatuses],
|
||||
);
|
||||
const categoryItems = useMemo<PromptCategoryItem[]>(() => [
|
||||
...instructionTemplates.map((template) => ({
|
||||
key: promptCategoryKey("builtin", template.id),
|
||||
@@ -441,11 +471,13 @@ export function PromptsPage({
|
||||
if (instructionMode === "form") {
|
||||
return (
|
||||
<PageTransition pageKey={`prompts:${instructionMode}`}>
|
||||
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
|
||||
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingBuiltinPrompt ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
|
||||
<PromptFormView
|
||||
key={editingBuiltinPrompt?.id || editingPromptId || "new"}
|
||||
lang={lang}
|
||||
promptForm={promptForm}
|
||||
editingPromptId={editingPromptId}
|
||||
editingBuiltinPrompt={editingBuiltinPrompt}
|
||||
loading={loading}
|
||||
onInstructionModeChange={onInstructionModeChange}
|
||||
onPromptFormFieldChange={onPromptFormFieldChange}
|
||||
@@ -605,6 +637,7 @@ export function PromptsPage({
|
||||
{instructionTemplates.filter((template) =>
|
||||
promptIsVisible(promptCategoryKey("builtin", template.id))).map((template) => {
|
||||
const enabled = template.id === activeBuiltinTemplateId;
|
||||
const customized = Boolean(builtinStatusById.get(template.id)?.customized);
|
||||
return (
|
||||
<PromptRow
|
||||
key={template.id}
|
||||
@@ -614,10 +647,22 @@ export function PromptsPage({
|
||||
loading={loading}
|
||||
toggleLabel={enabled ? copy.disable : copy.enable}
|
||||
onToggle={() => enabled ? onDisableInstruction() : onEnableBuiltinPrompt(template.id)}
|
||||
actions={(
|
||||
<div className="cx-prompts-icon-actions">
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} />}
|
||||
label={copy.editBuiltin}
|
||||
size="sm"
|
||||
onClick={() => onEditBuiltinPrompt(template.id)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{enabled && (
|
||||
{(enabled || customized) && (
|
||||
<div className="cx-prompts-row-meta">
|
||||
<StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>
|
||||
{enabled && <StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>}
|
||||
{customized && <StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>}
|
||||
</div>
|
||||
)}
|
||||
</PromptRow>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FilePlus2,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -32,8 +37,10 @@ export type ProviderRow = {
|
||||
wireApi: string;
|
||||
requiresOpenaiAuth: boolean;
|
||||
isCurrent: boolean;
|
||||
isDefaultOfficial?: boolean;
|
||||
sourceLabel?: string;
|
||||
editable?: boolean;
|
||||
duplicable?: boolean;
|
||||
deletable?: boolean;
|
||||
testable?: boolean;
|
||||
testingKey?: string;
|
||||
@@ -50,8 +57,10 @@ export type ProviderFormValue = {
|
||||
};
|
||||
|
||||
export type OfficialFormValue = {
|
||||
providerName: string;
|
||||
model: string;
|
||||
authJson: string;
|
||||
configText: string;
|
||||
};
|
||||
|
||||
export type ProviderCopy = {
|
||||
@@ -65,6 +74,7 @@ export type ProviderCopy = {
|
||||
enableLabel: string;
|
||||
testLabel: string;
|
||||
editLabel: string;
|
||||
duplicateLabel: string;
|
||||
removeLabel: string;
|
||||
deleteTitle: string;
|
||||
deleteDescription: (providerName: string) => string;
|
||||
@@ -79,7 +89,15 @@ export type ProviderCopy = {
|
||||
authPathLabel: string;
|
||||
officialCurrentLabel: string;
|
||||
officialAuthLabel: string;
|
||||
officialTomlLabel: string;
|
||||
officialSaveLabel: string;
|
||||
loadCcSwitchOfficialLabel: string;
|
||||
restoreOfficialLabel: string;
|
||||
resetOfficialLabel: string;
|
||||
resetOfficialTitle: string;
|
||||
resetOfficialDescription: string;
|
||||
resetOfficialCancelLabel: string;
|
||||
resetOfficialConfirmLabel: string;
|
||||
cancelLabel: string;
|
||||
formEyebrow: string;
|
||||
formAddTitle: string;
|
||||
@@ -123,8 +141,14 @@ export type ProvidersPageProps = {
|
||||
testingId: string;
|
||||
actionBusy?: string;
|
||||
editingProviderId: string | null;
|
||||
creatingProvider: boolean;
|
||||
providerKind: "api" | "official";
|
||||
providerForm: ProviderFormValue;
|
||||
officialForm: OfficialFormValue;
|
||||
officialProfileIsDefault: boolean;
|
||||
canLoadCurrentOfficial: boolean;
|
||||
officialAuthRef?: Ref<HTMLTextAreaElement>;
|
||||
officialTomlRef?: Ref<HTMLTextAreaElement>;
|
||||
officialInfo: ProviderOfficialInfo;
|
||||
providerAuthPreview: ReactNode;
|
||||
providerTomlDraft: string;
|
||||
@@ -134,13 +158,21 @@ export type ProvidersPageProps = {
|
||||
fetchingModels: boolean;
|
||||
onImportCcSwitch: () => void;
|
||||
onAddProvider: () => void;
|
||||
onProviderKindChange: (kind: "api" | "official") => void;
|
||||
onLoadCcSwitchOfficial: () => void;
|
||||
onLoadCurrentOfficial: () => void;
|
||||
onRestoreOfficial: () => void;
|
||||
onResetOfficial: () => void;
|
||||
onEnableProvider: (row: ProviderRow) => void;
|
||||
onTestProvider: (row: ProviderRow) => void;
|
||||
onEditProvider: (row: ProviderRow) => void;
|
||||
onDuplicateProvider: (row: ProviderRow) => void;
|
||||
onDeleteProvider: (row: ProviderRow) => Promise<boolean>;
|
||||
onCancelMode: () => void;
|
||||
onOfficialNameChange: (value: string) => void;
|
||||
onOfficialModelChange: (value: string) => void;
|
||||
onOfficialAuthChange: (value: string) => void;
|
||||
onOfficialConfigChange: (value: string) => void;
|
||||
onSaveOfficial: () => void;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
@@ -170,6 +202,145 @@ function Field({ label, children, className }: FieldProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type ContextWindowValues = {
|
||||
contextWindow: number | null;
|
||||
compactTokenLimit: number | null;
|
||||
};
|
||||
|
||||
type ContextWindowConfig = ContextWindowValues & {
|
||||
configText: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
function ContextWindowControl({
|
||||
lang,
|
||||
configText,
|
||||
disabled,
|
||||
onConfigChange,
|
||||
onBusyChange,
|
||||
}: {
|
||||
lang: "zh" | "en";
|
||||
configText: string;
|
||||
disabled: boolean;
|
||||
onConfigChange: (text: string) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
}) {
|
||||
const [settings, setSettings] = useState<ContextWindowConfig | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [changing, setChanging] = useState(false);
|
||||
const currentText = useRef(configText);
|
||||
const requestId = useRef(0);
|
||||
const previousValues = useRef<ContextWindowValues | null>(null);
|
||||
currentText.current = configText;
|
||||
|
||||
useEffect(() => {
|
||||
const id = ++requestId.current;
|
||||
let mounted = true;
|
||||
setError("");
|
||||
const timer = setTimeout(() => {
|
||||
void invoke<ContextWindowConfig>("update_codex_context_window", {
|
||||
configText,
|
||||
enabled: null,
|
||||
previousValues: null,
|
||||
}).then((result) => {
|
||||
if (mounted && requestId.current === id && currentText.current === configText) {
|
||||
setSettings(result);
|
||||
}
|
||||
}).catch((cause: unknown) => {
|
||||
if (mounted && requestId.current === id && currentText.current === configText) {
|
||||
setSettings(null);
|
||||
setError(String(cause));
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
return () => {
|
||||
mounted = false;
|
||||
++requestId.current;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [configText]);
|
||||
|
||||
const toggle = async (enabled: boolean) => {
|
||||
if (disabled || changing || !settings || settings.configText !== configText) return;
|
||||
const originalText = configText;
|
||||
const id = ++requestId.current;
|
||||
setChanging(true);
|
||||
onBusyChange(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<ContextWindowConfig>("update_codex_context_window", {
|
||||
configText: originalText,
|
||||
enabled,
|
||||
previousValues: enabled ? null : previousValues.current,
|
||||
});
|
||||
if (requestId.current !== id || currentText.current !== originalText) return;
|
||||
previousValues.current = enabled
|
||||
? { contextWindow: settings.contextWindow, compactTokenLimit: settings.compactTokenLimit }
|
||||
: null;
|
||||
setSettings(result);
|
||||
onConfigChange(result.configText);
|
||||
} catch (cause: unknown) {
|
||||
if (requestId.current === id && currentText.current === originalText) setError(String(cause));
|
||||
} finally {
|
||||
setChanging(false);
|
||||
onBusyChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cx-providers-context-control">
|
||||
<Checkbox
|
||||
className="cx-providers-checkbox cx-providers-context-checkbox"
|
||||
label={lang === "zh" ? "开启 1M 上下文窗口" : "Enable 1M context window"}
|
||||
checked={Boolean(settings?.enabled)}
|
||||
onCheckedChange={(enabled) => void toggle(enabled)}
|
||||
disabled={disabled || changing || !settings || settings.configText !== configText}
|
||||
/>
|
||||
{error ? (
|
||||
<span className="cx-providers-context-error" role="status" title={error}>
|
||||
{lang === "zh" ? "请先修正 config.toml 的格式或上下文字段" : "Check the TOML syntax and context settings first"}
|
||||
</span>
|
||||
) : settings?.enabled && settings.configText === configText ? (
|
||||
<span className="cx-providers-context-hint">
|
||||
{settings.compactTokenLimit !== null
|
||||
? lang === "zh"
|
||||
? `自动压缩阈值:${settings.compactTokenLimit.toLocaleString("zh-CN")} tokens`
|
||||
: `Auto-compact at ${settings.compactTokenLimit.toLocaleString("en-US")} tokens`
|
||||
: lang === "zh" ? "自动压缩使用 Codex 默认值" : "Codex determines the compaction limit"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="cx-providers-context-hint">
|
||||
{lang === "zh" ? "保存后生效,需模型支持" : "Applies on save; requires model support"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderKindSelect({
|
||||
lang,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
disabled,
|
||||
}: Pick<ProvidersPageProps, "lang" | "creatingProvider" | "providerKind" | "onProviderKindChange"> & { disabled: boolean }) {
|
||||
if (!creatingProvider) return null;
|
||||
return (
|
||||
<div className="cx-providers-form-grid cx-providers-form-grid--single">
|
||||
<Field label={lang === "zh" ? "供应商类型" : "Provider type"}>
|
||||
<select
|
||||
value={providerKind}
|
||||
onChange={(event) => onProviderKindChange(event.target.value === "official" ? "official" : "api")}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="api">{lang === "zh" ? "第三方 API" : "Third-party API"}</option>
|
||||
<option value="official">{lang === "zh" ? "官方 Codex 登录" : "Official Codex login"}</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAvatar({ row }: { row: ProviderRow }) {
|
||||
const initial = row.providerName.trim().slice(0, 1).toUpperCase() || "?";
|
||||
const className = [
|
||||
@@ -220,13 +391,16 @@ function ListPage({
|
||||
actionBusy,
|
||||
onImportCcSwitch,
|
||||
onAddProvider,
|
||||
onRestoreOfficial,
|
||||
onEnableProvider,
|
||||
onTestProvider,
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
if (!deleting) setProviderToDelete(null);
|
||||
@@ -255,12 +429,12 @@ function ListPage({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--secondary"
|
||||
onClick={onImportCcSwitch}
|
||||
disabled={loading || Boolean(actionBusy)}
|
||||
disabled={providerActionsBusy}
|
||||
>
|
||||
{actionBusy === "importCcSwitch" ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
|
||||
{copy.importLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={providerActionsBusy}>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
{copy.addLabel}
|
||||
</button>
|
||||
@@ -294,23 +468,34 @@ function ListPage({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
|
||||
onClick={() => onEnableProvider(row)}
|
||||
disabled={loading || row.isCurrent}
|
||||
disabled={providerActionsBusy || row.isCurrent}
|
||||
>
|
||||
{copy.enableLabel}
|
||||
</button>
|
||||
{row.source === "official" && row.isDefaultOfficial && (
|
||||
<ActionIconButton
|
||||
icon={RotateCcw}
|
||||
label={copy.restoreOfficialLabel}
|
||||
onClick={onRestoreOfficial}
|
||||
disabled={providerActionsBusy}
|
||||
/>
|
||||
)}
|
||||
{row.testable !== false && (
|
||||
<ActionIconButton
|
||||
icon={isTesting ? Loader2 : Activity}
|
||||
label={copy.testLabel}
|
||||
onClick={() => onTestProvider(row)}
|
||||
disabled={loading || isTesting}
|
||||
disabled={providerActionsBusy || isTesting}
|
||||
/>
|
||||
)}
|
||||
{row.editable !== false && (
|
||||
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={loading} />
|
||||
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{row.duplicable && (
|
||||
<ActionIconButton icon={Copy} label={copy.duplicateLabel} onClick={() => onDuplicateProvider(row)} disabled={providerActionsBusy} />
|
||||
)}
|
||||
{row.deletable && (
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={loading} danger />
|
||||
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
@@ -354,7 +539,7 @@ function ListPage({
|
||||
);
|
||||
}
|
||||
|
||||
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void }) {
|
||||
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel, disabled = false }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<header className="cx-providers-form-header">
|
||||
<div>
|
||||
@@ -362,7 +547,7 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel} disabled={disabled}>
|
||||
<ArrowLeft size={15} aria-hidden="true" />
|
||||
{cancelLabel}
|
||||
</button>
|
||||
@@ -371,44 +556,146 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
|
||||
}
|
||||
|
||||
function OfficialForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
officialForm,
|
||||
officialProfileIsDefault,
|
||||
canLoadCurrentOfficial,
|
||||
officialAuthRef,
|
||||
officialTomlRef,
|
||||
officialInfo,
|
||||
loading,
|
||||
actionBusy,
|
||||
onCancelMode,
|
||||
onOfficialNameChange,
|
||||
onOfficialModelChange,
|
||||
onOfficialAuthChange,
|
||||
onOfficialConfigChange,
|
||||
onSaveOfficial,
|
||||
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial">) {
|
||||
onLoadCcSwitchOfficial,
|
||||
onLoadCurrentOfficial,
|
||||
onRestoreOfficial,
|
||||
onResetOfficial,
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
|
||||
const loadingCurrent = actionBusy === "loadCurrentOfficial";
|
||||
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch || loadingCurrent || contextWindowBusy;
|
||||
const showDefaultActions = officialProfileIsDefault && !creatingProvider;
|
||||
const profileHint = lang === "zh"
|
||||
? "认证内容可留空;保存并启用后,在 Codex 中完成登录。复制官方供应商会保留原认证,并可修改名称。"
|
||||
: "Authentication may be left empty. Save and enable this provider, then sign in through Codex. A copied official provider keeps its authentication and can be renamed.";
|
||||
|
||||
const confirmReset = () => {
|
||||
if (!showDefaultActions || formBusy) return;
|
||||
setResetConfirmOpen(false);
|
||||
onResetOfficial();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
|
||||
<ModeHeader
|
||||
eyebrow={copy.officialEyebrow}
|
||||
title={creatingProvider ? copy.formAddTitle : copy.officialTitle}
|
||||
description={showDefaultActions ? `${copy.officialHint} ${profileHint}` : profileHint}
|
||||
cancelLabel={copy.cancelLabel}
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
|
||||
<div className="cx-providers-info-grid">
|
||||
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
|
||||
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
|
||||
<div><span>{copy.officialCurrentLabel}</span><code>{officialInfo.current}</code></div>
|
||||
</div>
|
||||
<div className="cx-providers-form-grid cx-providers-form-grid--single">
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={officialForm.providerName} onChange={(event) => onOfficialNameChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
</div>
|
||||
<div className="cx-providers-editor-field">
|
||||
<div className="cx-providers-context-heading">
|
||||
<span>{copy.officialTomlLabel}</span>
|
||||
<ContextWindowControl lang={lang} configText={officialForm.configText} onConfigChange={onOfficialConfigChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
</div>
|
||||
<textarea
|
||||
ref={officialTomlRef}
|
||||
className="cx-providers-code-editor cx-providers-toml-editor"
|
||||
value={officialForm.configText}
|
||||
aria-label={copy.officialTomlLabel}
|
||||
onChange={(event) => onOfficialConfigChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
|
||||
<textarea
|
||||
ref={officialAuthRef}
|
||||
className="cx-providers-code-editor cx-providers-auth-editor"
|
||||
value={officialForm.authJson}
|
||||
onChange={(event) => onOfficialAuthChange(event.target.value)}
|
||||
wrap="off"
|
||||
disabled={formBusy}
|
||||
wrap="soft"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save cx-providers-official-actions">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onLoadCurrentOfficial} disabled={formBusy || !canLoadCurrentOfficial}>
|
||||
{loadingCurrent
|
||||
? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" />
|
||||
: <Download size={15} aria-hidden="true" />}
|
||||
{lang === "zh" ? "读取当前官方登录" : "Load current official login"}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onLoadCcSwitchOfficial} disabled={formBusy}>
|
||||
{loadingCcSwitch
|
||||
? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" />
|
||||
: <Download size={15} aria-hidden="true" />}
|
||||
{copy.loadCcSwitchOfficialLabel}
|
||||
</button>
|
||||
{showDefaultActions && (
|
||||
<>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={formBusy}>
|
||||
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
|
||||
</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
|
||||
</div>
|
||||
<ModalShell
|
||||
open={resetConfirmOpen && showDefaultActions}
|
||||
onClose={() => setResetConfirmOpen(false)}
|
||||
title={copy.resetOfficialTitle}
|
||||
description={copy.resetOfficialDescription}
|
||||
size="sm"
|
||||
closeLabel={copy.resetOfficialCancelLabel}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setResetConfirmOpen(false)} data-initial-focus>{copy.resetOfficialCancelLabel}</Button>
|
||||
<Button variant="danger" icon={<FilePlus2 size={16} />} onClick={confirmReset}>{copy.resetOfficialConfirmLabel}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="cx-provider-delete-warning">
|
||||
<span aria-hidden="true"><AlertTriangle size={22} /></span>
|
||||
<strong>config.toml + auth.json</strong>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
providerForm,
|
||||
loading,
|
||||
editingProviderId,
|
||||
@@ -430,9 +717,11 @@ function ProviderForm({
|
||||
onProviderTomlDraftChange,
|
||||
onResetProviderToml,
|
||||
onSaveProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
const modelListId = useId();
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -442,7 +731,9 @@ function ProviderForm({
|
||||
description={copy.formHint}
|
||||
cancelLabel={copy.cancelLabel}
|
||||
onCancel={onCancelMode}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
|
||||
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading">
|
||||
@@ -456,14 +747,15 @@ function ProviderForm({
|
||||
value={providerForm.apiKey}
|
||||
onChange={(event) => onApiKeyChange(event.target.value)}
|
||||
placeholder={copy.apiKeyPlaceholder}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel}>
|
||||
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} disabled={formBusy}>
|
||||
{apiKeyVisible ? <EyeOff size={15} aria-hidden="true" /> : <Eye size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} /></Field>
|
||||
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} disabled={formBusy} /></Field>
|
||||
<Field label={copy.modelLabel}>
|
||||
<div className="cx-providers-model-input-row">
|
||||
<input
|
||||
@@ -471,6 +763,7 @@ function ProviderForm({
|
||||
list={availableModels.length ? modelListId : undefined}
|
||||
aria-label={copy.modelLabel}
|
||||
onChange={(event) => onProviderModelChange(event.target.value)}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -495,6 +788,7 @@ function ProviderForm({
|
||||
className="cx-providers-model-select"
|
||||
value=""
|
||||
aria-label={copy.chooseModelLabel(availableModels.length)}
|
||||
disabled={formBusy}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onProviderModelChange(event.target.value);
|
||||
}}
|
||||
@@ -506,7 +800,7 @@ function ProviderForm({
|
||||
)}
|
||||
</Field>
|
||||
<Field label={copy.wireApiLabel}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)} disabled={formBusy}>
|
||||
<option value="responses">responses</option>
|
||||
<option value="chat">chat</option>
|
||||
</select>
|
||||
@@ -516,6 +810,7 @@ function ProviderForm({
|
||||
checked={providerForm.requiresOpenaiAuth}
|
||||
onCheckedChange={onRequiresAuthChange}
|
||||
label={copy.requiresAuthLabel}
|
||||
disabled={formBusy}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -528,13 +823,16 @@ function ProviderForm({
|
||||
<section className="cx-providers-form-section">
|
||||
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
<div className="cx-providers-context-actions">
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={onProviderTomlDraftChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} spellCheck={false} />
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" aria-label={copy.tomlTitle} value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
|
||||
</section>
|
||||
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={loading}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy}>
|
||||
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{loading ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,10 @@ export type SessionSyncStatus = {
|
||||
topLevelThreads: number;
|
||||
subagentThreads: number;
|
||||
mismatchedThreads: number;
|
||||
mismatchedSessions: number;
|
||||
needsSync: boolean;
|
||||
scanComplete: boolean;
|
||||
scanFailures: string[];
|
||||
backupDir?: string | null;
|
||||
warnings: string[];
|
||||
sessions: SessionPreview[];
|
||||
@@ -153,13 +156,14 @@ export function SessionManagementPage({
|
||||
? {
|
||||
syncEyebrow: "会话同步",
|
||||
title: "会话管理",
|
||||
description: "检查本地会话是否跟当前供应商一致,需要时一键同步。不会修改聊天内容。",
|
||||
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
|
||||
syncTo: "同步到",
|
||||
check: "检查会话",
|
||||
checking: "检查中...",
|
||||
sync: "同步会话",
|
||||
syncing: "同步中...",
|
||||
clickToCheck: "点击检查会话",
|
||||
scanIncomplete: "无法确认同步状态,请查看下方原因",
|
||||
needsSync: (count: number) => `有 ${count} 条会话需要同步`,
|
||||
allSynced: "全部会话已同步",
|
||||
sessionCount: (count: number) => `${count} 条会话`,
|
||||
@@ -201,13 +205,14 @@ export function SessionManagementPage({
|
||||
: {
|
||||
syncEyebrow: "SESSION SYNC",
|
||||
title: "Session management",
|
||||
description: "Check whether local sessions match the current provider and sync them when needed. Chat content is not changed.",
|
||||
description: "Keep local sessions in one history shared by official and third-party providers. Chat content is not changed.",
|
||||
syncTo: "Sync to",
|
||||
check: "Check sessions",
|
||||
checking: "Checking...",
|
||||
sync: "Sync sessions",
|
||||
syncing: "Syncing...",
|
||||
clickToCheck: "Check sessions to get started",
|
||||
scanIncomplete: "Unable to verify sync status. See the reason below.",
|
||||
needsSync: (count: number) => `${count} session(s) need syncing`,
|
||||
allSynced: "All sessions are synced",
|
||||
sessionCount: (count: number) => `${count} sessions`,
|
||||
@@ -247,6 +252,11 @@ export function SessionManagementPage({
|
||||
confirmDelete: (count: number) => `Delete ${count} permanently`,
|
||||
};
|
||||
|
||||
const scanIncomplete = Boolean(sessionStatus && !sessionStatus.scanComplete);
|
||||
const diagnostics = [
|
||||
...(sessionStatus?.scanFailures || []).map((message) => ({ message, blocking: true })),
|
||||
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
|
||||
];
|
||||
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
|
||||
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
|
||||
@@ -327,17 +337,17 @@ export function SessionManagementPage({
|
||||
{actionBusy === "checkSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <RefreshCw size={16} aria-hidden="true" />}
|
||||
{actionBusy === "checkSessions" ? copy.checking : copy.check}
|
||||
</button>
|
||||
<button type="button" className="cx-session-button cx-session-button--primary" onClick={onSyncSessions} disabled={loading || !sessionHasMismatches} aria-busy={actionBusy === "syncSessions"}>
|
||||
<button type="button" className="cx-session-button cx-session-button--primary" onClick={onSyncSessions} disabled={loading || scanIncomplete || !sessionHasMismatches} aria-busy={actionBusy === "syncSessions"}>
|
||||
{actionBusy === "syncSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <Zap size={16} aria-hidden="true" />}
|
||||
{actionBusy === "syncSessions" ? copy.syncing : copy.sync}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={cx("cx-session-summary", sessionHasMismatches ? "cx-session-summary--needs-sync" : "cx-session-summary--synced")}>
|
||||
<div className={cx("cx-session-summary", scanIncomplete || sessionHasMismatches ? "cx-session-summary--needs-sync" : "cx-session-summary--synced")}>
|
||||
<span className="cx-session-summary-status">
|
||||
{!sessionStatus ? <Info size={15} aria-hidden="true" /> : sessionHasMismatches ? <AlertCircle size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{!sessionStatus ? copy.clickToCheck : sessionHasMismatches ? copy.needsSync(sessionSyncCount) : copy.allSynced}
|
||||
{!sessionStatus ? <Info size={15} aria-hidden="true" /> : scanIncomplete || sessionHasMismatches ? <AlertCircle size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{!sessionStatus ? copy.clickToCheck : scanIncomplete ? copy.scanIncomplete : sessionHasMismatches ? copy.needsSync(sessionSyncCount) : copy.allSynced}
|
||||
</span>
|
||||
<span className="cx-session-summary-count">{copy.sessionCount(sessionStatus?.topLevelThreads ?? 0)}</span>
|
||||
</div>
|
||||
@@ -475,15 +485,15 @@ export function SessionManagementPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionStatus?.warnings?.length ? (
|
||||
<details className="cx-session-diagnostics">
|
||||
{diagnostics.length ? (
|
||||
<details className="cx-session-diagnostics" open={scanIncomplete || undefined}>
|
||||
<summary>
|
||||
<AlertCircle size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.diagnostics}</span>
|
||||
<small>{copy.diagnosticsCount(sessionStatus.warnings.length)}</small>
|
||||
<small>{copy.diagnosticsCount(diagnostics.length)}</small>
|
||||
</summary>
|
||||
<div className="cx-session-diagnostic-items">
|
||||
{sessionStatus.warnings.map((item, index) => <p key={`${index}-${item}`}><Info size={14} aria-hidden="true" />{item}</p>)}
|
||||
{diagnostics.map((item, index) => <p key={`${index}-${item.message}`}>{item.blocking ? <AlertCircle size={14} aria-hidden="true" /> : <Info size={14} aria-hidden="true" />}{item.message}</p>)}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderInput,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
PencilLine,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import { Button, IconButton, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
|
||||
import type { Lang, ManagedMcpServer, ManagedSkill, SkillsMcpImportPreview, SkillsMcpState } from "../types";
|
||||
import "../styles/skills-prompts-pages.css";
|
||||
|
||||
export type SkillsMcpTab = "mcp" | "skills";
|
||||
export type SkillsMcpNoteKind = "mcp" | "skill";
|
||||
|
||||
type MaybeAsyncAction = () => void | Promise<void>;
|
||||
|
||||
@@ -27,6 +29,7 @@ export type SkillsMcpPageProps = {
|
||||
state: SkillsMcpState | null;
|
||||
activeTab: SkillsMcpTab;
|
||||
actionBusy: string;
|
||||
noteBusyKey: string;
|
||||
importOpen: boolean;
|
||||
importPreview: SkillsMcpImportPreview | null;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
@@ -40,6 +43,14 @@ export type SkillsMcpPageProps = {
|
||||
onCheckUpdates: MaybeAsyncAction;
|
||||
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onSaveNote: (itemKind: SkillsMcpNoteKind, id: string, note: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type NoteTarget = {
|
||||
itemKind: SkillsMcpNoteKind;
|
||||
id: string;
|
||||
name: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type SkillsMcpCopy = ReturnType<typeof getCopy>;
|
||||
@@ -65,6 +76,16 @@ function getCopy(lang: Lang) {
|
||||
enableSkill: "启用 Skill",
|
||||
disableSkill: "禁用 Skill",
|
||||
updateStatus: "更新状态",
|
||||
addNote: "添加备注",
|
||||
editNote: "编辑备注",
|
||||
noteTitle: "自定义备注",
|
||||
noteDescription: (name: string) => `为“${name}”添加便于识别的说明。`,
|
||||
noteLabel: "备注内容",
|
||||
notePlaceholder: "例如:用于查询项目文档,依赖本地 Node.js",
|
||||
noteEmptyHint: "留空保存将清除现有备注",
|
||||
noteCount: (count: number) => `${count} / 1000`,
|
||||
saveNote: "保存备注",
|
||||
savingNote: "保存中",
|
||||
importTitle: "确认导入已有内容",
|
||||
importDescription: "以下内容来自本机现有配置。导入后可在此统一启用或禁用。",
|
||||
noImportItems: "没有发现可导入的已有 Skills / MCP。",
|
||||
@@ -94,6 +115,16 @@ function getCopy(lang: Lang) {
|
||||
enableSkill: "Enable Skill",
|
||||
disableSkill: "Disable Skill",
|
||||
updateStatus: "Update status",
|
||||
addNote: "Add note",
|
||||
editNote: "Edit note",
|
||||
noteTitle: "Custom note",
|
||||
noteDescription: (name: string) => `Add a description that helps you recognize “${name}”.`,
|
||||
noteLabel: "Note",
|
||||
notePlaceholder: "For example: searches project docs; requires local Node.js",
|
||||
noteEmptyHint: "Save an empty note to remove it",
|
||||
noteCount: (count: number) => `${count} / 1000`,
|
||||
saveNote: "Save note",
|
||||
savingNote: "Saving",
|
||||
importTitle: "Confirm import",
|
||||
importDescription: "These items were found in the existing local configuration. Import them to manage their state here.",
|
||||
noImportItems: "No existing Skills / MCP items were found.",
|
||||
@@ -116,22 +147,33 @@ function McpRow({
|
||||
disabled,
|
||||
copy,
|
||||
onToggle,
|
||||
onEditNote,
|
||||
}: {
|
||||
server: ManagedMcpServer;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
copy: SkillsMcpCopy;
|
||||
onToggle: SkillsMcpPageProps["onToggleMcp"];
|
||||
onEditNote: (target: NoteTarget) => void;
|
||||
}) {
|
||||
const name = server.name || server.id;
|
||||
const details = [server.summary, server.transport, server.source].filter(Boolean).join("\n");
|
||||
const details = [server.note, server.summary, server.transport, server.source].filter(Boolean).join("\n");
|
||||
return (
|
||||
<article className="cx-skills-row">
|
||||
<div className="cx-skills-row-copy">
|
||||
<div className={cx("cx-skills-row-copy", Boolean(server.note) && "cx-skills-row-copy--noted")}>
|
||||
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
|
||||
{server.note && <span className="cx-skills-row-note" title={server.note}>{server.note}</span>}
|
||||
</div>
|
||||
<div className="cx-skills-row-control">
|
||||
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} strokeWidth={1.9} />}
|
||||
label={server.note ? copy.editNote : copy.addNote}
|
||||
size="sm"
|
||||
className="cx-skills-note-button"
|
||||
onClick={() => onEditNote({ itemKind: "mcp", id: server.id, name, note: server.note || "" })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(enabled) => void onToggle(server.id, enabled)}
|
||||
@@ -149,12 +191,14 @@ function SkillRow({
|
||||
disabled,
|
||||
copy,
|
||||
onToggle,
|
||||
onEditNote,
|
||||
}: {
|
||||
skill: ManagedSkill;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
copy: SkillsMcpCopy;
|
||||
onToggle: SkillsMcpPageProps["onToggleSkill"];
|
||||
onEditNote: (target: NoteTarget) => void;
|
||||
}) {
|
||||
const name = skill.name || skill.directory;
|
||||
const updateTone = skill.updateStatus.includes("失败") || skill.updateStatus.toLowerCase().includes("fail")
|
||||
@@ -164,20 +208,29 @@ function SkillRow({
|
||||
: "neutral";
|
||||
|
||||
const showUpdateStatus = Boolean(skill.updateStatus && skill.updateStatus !== "未检查");
|
||||
const details = [skill.description, skill.source].filter(Boolean).join("\n");
|
||||
const details = [skill.note, skill.description, skill.source].filter(Boolean).join("\n");
|
||||
|
||||
return (
|
||||
<article className="cx-skills-row">
|
||||
<div className="cx-skills-row-copy">
|
||||
<div className={cx("cx-skills-row-copy", Boolean(skill.note) && "cx-skills-row-copy--noted")}>
|
||||
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
|
||||
{skill.note && <span className="cx-skills-row-note" title={skill.note}>{skill.note}</span>}
|
||||
{showUpdateStatus && (
|
||||
<StatusBadge tone={updateTone} dot={false} title={copy.updateStatus}>
|
||||
<StatusBadge tone={updateTone} dot={false} title={`${copy.updateStatus}: ${skill.updateStatus}`}>
|
||||
{skill.updateStatus}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="cx-skills-row-control">
|
||||
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
|
||||
<IconButton
|
||||
icon={<PencilLine size={15} strokeWidth={1.9} />}
|
||||
label={skill.note ? copy.editNote : copy.addNote}
|
||||
size="sm"
|
||||
className="cx-skills-note-button"
|
||||
onClick={() => onEditNote({ itemKind: "skill", id: skill.id, name, note: skill.note || "" })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={(enabled) => void onToggle(skill.id, enabled)}
|
||||
@@ -255,6 +308,7 @@ export function SkillsMcpPage({
|
||||
state,
|
||||
activeTab,
|
||||
actionBusy,
|
||||
noteBusyKey,
|
||||
importOpen,
|
||||
importPreview,
|
||||
zipInputRef,
|
||||
@@ -268,12 +322,34 @@ export function SkillsMcpPage({
|
||||
onCheckUpdates,
|
||||
onToggleSkill,
|
||||
onToggleMcp,
|
||||
onSaveNote,
|
||||
}: SkillsMcpPageProps) {
|
||||
const copy = getCopy(lang);
|
||||
const tabId = useId();
|
||||
const anyBusy = Boolean(actionBusy);
|
||||
const importBusy = actionBusy === "importExistingSkillsMcp";
|
||||
const activeItems = activeTab === "mcp" ? state?.mcpServers ?? [] : state?.skills ?? [];
|
||||
const [noteTarget, setNoteTarget] = useState<NoteTarget | null>(null);
|
||||
const [noteDraft, setNoteDraft] = useState("");
|
||||
const noteBusy = noteTarget
|
||||
? noteBusyKey === `note:${noteTarget.itemKind}:${noteTarget.id}`
|
||||
: false;
|
||||
const openNoteEditor = (target: NoteTarget) => {
|
||||
setNoteTarget(target);
|
||||
setNoteDraft(target.note);
|
||||
};
|
||||
const closeNoteEditor = () => {
|
||||
if (!noteBusy) setNoteTarget(null);
|
||||
};
|
||||
const updateNoteDraft = (value: string) => {
|
||||
setNoteDraft(Array.from(value).slice(0, 1000).join(""));
|
||||
};
|
||||
const saveNote = async () => {
|
||||
if (!noteTarget || noteBusy) return;
|
||||
if (await onSaveNote(noteTarget.itemKind, noteTarget.id, noteDraft)) {
|
||||
setNoteTarget(null);
|
||||
}
|
||||
};
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onInstallZip(event.currentTarget.files?.[0]);
|
||||
};
|
||||
@@ -395,6 +471,7 @@ export function SkillsMcpPage({
|
||||
disabled={anyBusy}
|
||||
copy={copy}
|
||||
onToggle={onToggleMcp}
|
||||
onEditNote={openNoteEditor}
|
||||
/>
|
||||
))
|
||||
) : state.skills.length === 0 ? (
|
||||
@@ -407,6 +484,7 @@ export function SkillsMcpPage({
|
||||
disabled={anyBusy}
|
||||
copy={copy}
|
||||
onToggle={onToggleSkill}
|
||||
onEditNote={openNoteEditor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -453,6 +531,48 @@ export function SkillsMcpPage({
|
||||
>
|
||||
<ImportPreviewContent preview={importPreview} copy={copy} />
|
||||
</ModalShell>
|
||||
|
||||
<ModalShell
|
||||
open={Boolean(noteTarget)}
|
||||
onClose={closeNoteEditor}
|
||||
title={copy.noteTitle}
|
||||
description={noteTarget ? copy.noteDescription(noteTarget.name) : undefined}
|
||||
size="sm"
|
||||
closeOnBackdrop={!noteBusy}
|
||||
closeOnEscape={!noteBusy}
|
||||
showCloseButton={!noteBusy}
|
||||
className="cx-skills-note-modal"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeNoteEditor} disabled={noteBusy}>
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
icon={noteBusy ? <Loader2 className="cx-skills-spin" /> : <PencilLine />}
|
||||
onClick={() => void saveNote()}
|
||||
disabled={noteBusy}
|
||||
>
|
||||
{noteBusy ? copy.savingNote : copy.saveNote}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<label className="cx-skills-note-field">
|
||||
<span>{copy.noteLabel}</span>
|
||||
<textarea
|
||||
value={noteDraft}
|
||||
onChange={(event) => updateNoteDraft(event.target.value)}
|
||||
placeholder={copy.notePlaceholder}
|
||||
rows={5}
|
||||
disabled={noteBusy}
|
||||
data-initial-focus
|
||||
/>
|
||||
</label>
|
||||
<div className="cx-skills-note-meta">
|
||||
<span>{copy.noteEmptyHint}</span>
|
||||
<span>{copy.noteCount(Array.from(noteDraft).length)}</span>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Activity, AlertCircle, ArrowDownLeft, ArrowUpRight, BarChart3,
|
||||
CalendarDays, Check, Database, Loader2, MessageSquare, RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { UsageDay, UsageModel, UsageRange, UsageStatistics } from "../usageTypes";
|
||||
import "../styles/usage-statistics.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
|
||||
export type UsageStatisticsPageProps = {
|
||||
lang: Language;
|
||||
configDir: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function formatUsageTokens(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
const units: [number, string][] = [[1_000_000_000, "B"], [1_000_000, "M"], [1_000, "K"]];
|
||||
for (const [divisor, unit] of units) {
|
||||
if (value >= divisor) {
|
||||
const scaled = value / divisor;
|
||||
return `${Number(scaled.toFixed(scaled < 10 ? 2 : 1))}${unit}`;
|
||||
}
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
const exact = (value: number, lang: Language) => value.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
|
||||
const ratio = (part: number, total: number) => total > 0 ? Math.min(100, Math.max(0, part / total * 100)) : 0;
|
||||
const percentage = (value: number) => `${Number(value.toFixed(1))}%`;
|
||||
|
||||
function modelLabel(model: string, lang: Language): string {
|
||||
if (model === "unknown") return lang === "zh" ? "未知模型" : "Unknown model";
|
||||
if (model === "multiple") return lang === "zh" ? "多个模型" : "Multiple models";
|
||||
return model;
|
||||
}
|
||||
|
||||
function sessionTitleLabel(title: string, lang: Language): string {
|
||||
return title && title !== "未命名会话" ? title : lang === "zh" ? "未命名会话" : "Untitled session";
|
||||
}
|
||||
|
||||
function dateLabel(date: string, lang: Language, includeYear = false): string {
|
||||
const value = new Date(date.length === 10 ? `${date}T12:00:00` : date);
|
||||
if (Number.isNaN(value.getTime())) return date;
|
||||
return value.toLocaleDateString(lang === "zh" ? "zh-CN" : "en-US", {
|
||||
...(includeYear ? { year: "numeric" as const } : {}), month: "short", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function timestampLabel(date: string, lang: Language): string {
|
||||
const value = new Date(date);
|
||||
return Number.isNaN(value.getTime()) ? date : value.toLocaleString(lang === "zh" ? "zh-CN" : "en-US", {
|
||||
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function copyFor(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "Token 用量统计",
|
||||
description: "查看 Codex 的 Token 使用情况,了解用量趋势与模型分布。",
|
||||
range: "统计范围", ranges: { today: "今天", "7d": "近 7 天", "30d": "近 30 天", all: "全部" },
|
||||
model: "模型", allModels: "全部模型", refresh: "刷新用量", refreshing: "更新中", retry: "重试",
|
||||
total: "总 Token 用量", totalHint: "输入 + 输出", input: "输入 Token", output: "输出 Token",
|
||||
freshInput: "未缓存输入", cache: "缓存输入", cached: "已缓存", cacheRate: "缓存命中率",
|
||||
inputHint: "包含缓存输入", outputHint: "包含推理 Token", reasoning: "推理 Token",
|
||||
sessions: "活跃会话", sessionsHint: "主会话数量,含子代理产生的用量", tokens: "Token",
|
||||
trend: "用量趋势", trendHint: "按日查看输入、缓存和输出的构成", peak: "最高用量日",
|
||||
legendLabel: "Token 构成", chartLabel: "每日 Token 用量;左右方向键切换日期",
|
||||
breakdown: "每日明细", date: "日期", totalColumn: "合计", modelTitle: "模型分布",
|
||||
modelHint: "按 Token 用量排序", modelCount: "个模型", modelFilter: "筛选此模型",
|
||||
recent: "最近会话", recentHint: "展示最近 10 个有用量的主会话;子代理用量合并到所属主会话,总量包含全部符合条件的记录。",
|
||||
session: "会话", lastActive: "最近活动", noData: "这里将记录你的 Codex 用量",
|
||||
noDataDescription: "当前范围内还没有可统计的会话记录。使用 Codex 后点击刷新,或试试更大的日期范围。",
|
||||
showAll: "查看全部时间", loading: "正在整理本地会话用量…",
|
||||
error: "暂时无法读取用量", stale: "刷新失败,下面保留上次读取的结果。",
|
||||
partial: "部分记录未计入", partialHint: "部分本地记录不完整,当前结果仅包含可读取的用量。",
|
||||
source: "本地会话记录", updated: "更新于", notes: "缓存输入已包含在输入 Token 中,推理 Token 已包含在输出 Token 中。历史记录缺失的用量无法补算。",
|
||||
scanned: "已检查", files: "份记录", filtered: "当前模型", activity: "使用概览",
|
||||
noModels: "暂无模型用量", zeroDay: "当天无用量",
|
||||
} : {
|
||||
title: "Token usage statistics",
|
||||
description: "Explore your Codex usage, daily activity, and model distribution.",
|
||||
range: "Date range", ranges: { today: "Today", "7d": "7 days", "30d": "30 days", all: "All time" },
|
||||
model: "Model", allModels: "All models", refresh: "Refresh usage", refreshing: "Updating", retry: "Retry",
|
||||
total: "Total token usage", totalHint: "Input + output", input: "Input tokens", output: "Output tokens",
|
||||
freshInput: "Uncached input", cache: "Cached input", cached: "cached", cacheRate: "Cache hit rate",
|
||||
inputHint: "Includes cached input", outputHint: "Includes reasoning tokens", reasoning: "Reasoning tokens",
|
||||
sessions: "Active sessions", sessionsHint: "Main conversations, including subagent usage", tokens: "tokens",
|
||||
trend: "Usage over time", trendHint: "Daily input, cache, and output breakdown", peak: "Peak day",
|
||||
legendLabel: "Token breakdown", chartLabel: "Daily token usage; use left and right arrows to explore dates",
|
||||
breakdown: "Daily details", date: "Date", totalColumn: "Total", modelTitle: "Model distribution",
|
||||
modelHint: "Ranked by token usage", modelCount: "models", modelFilter: "Filter by this model",
|
||||
recent: "Recent sessions", recentHint: "Showing the 10 most recent main conversations. Subagent usage is included in its parent conversation; totals include all matching records.",
|
||||
session: "Session", lastActive: "Last active", noData: "Your Codex usage will appear here",
|
||||
noDataDescription: "No usage records in this range yet. Refresh after using Codex, or try a broader date range.",
|
||||
showAll: "View all time", loading: "Reading local session usage…",
|
||||
error: "Unable to load usage", stale: "Refresh failed. The last available results are shown below.",
|
||||
partial: "Some records were excluded", partialHint: "Some local records are incomplete. These results include readable usage only.",
|
||||
source: "Local session records", updated: "Updated", notes: "Cached input is included in input tokens; reasoning is included in output tokens. Usage cannot be recovered from missing historical records.",
|
||||
scanned: "Checked", files: "records", filtered: "Selected model", activity: "Usage overview",
|
||||
noModels: "No model usage yet", zeroDay: "No usage this day",
|
||||
};
|
||||
}
|
||||
|
||||
type UsageCopy = ReturnType<typeof copyFor>;
|
||||
|
||||
function TokenValue({ value, lang, className = "" }: { value: number; lang: Language; className?: string }) {
|
||||
return <span className={`cx-usage-number ${className}`} title={exact(value, lang)} aria-label={exact(value, lang)}>{formatUsageTokens(value)}</span>;
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, children, hint, accent }: {
|
||||
icon: LucideIcon; label: string; children: ReactNode; hint: ReactNode; accent: string;
|
||||
}) {
|
||||
return <article className={`cx-usage-metric cx-usage-metric--${accent}`}>
|
||||
<div className="cx-usage-metric-label"><Icon size={15} aria-hidden="true" /><span>{label}</span></div>
|
||||
<div className="cx-usage-metric-value">{children}</div>
|
||||
<p>{hint}</p>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function Legend({ copy }: { copy: UsageCopy }) {
|
||||
return <div className="cx-usage-legend" aria-label={copy.legendLabel}>
|
||||
<span><i className="cx-usage-swatch--input" />{copy.freshInput}</span>
|
||||
<span><i className="cx-usage-swatch--cache" />{copy.cache}</span>
|
||||
<span><i className="cx-usage-swatch--output" />{copy.output}</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UsageTrend({ days, lang, copy }: { days: UsageDay[]; lang: Language; copy: UsageCopy }) {
|
||||
const chartId = useId();
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const barsRef = useRef<(SVGGElement | null)[]>([]);
|
||||
const peakIndex = days.reduce((winner, day, index) => day.totalTokens > (days[winner]?.totalTokens ?? -1) ? index : winner, 0);
|
||||
const selectedIndex = Math.max(0, selectedDate ? days.findIndex((day) => day.date === selectedDate) : peakIndex);
|
||||
const selected = days[selectedIndex];
|
||||
const chartWidth = Math.max(600, days.length * 13 + 56);
|
||||
const chartHeight = 218;
|
||||
const baseline = 181;
|
||||
const top = 15;
|
||||
const left = 48;
|
||||
const drawableWidth = chartWidth - left - 12;
|
||||
const max = Math.max(...days.map((day) => day.inputTokens + day.outputTokens), 1);
|
||||
const ceiling = max <= 5 ? 5 : Math.ceil(max / 10 ** Math.floor(Math.log10(max))) * 10 ** Math.floor(Math.log10(max));
|
||||
const step = drawableWidth / Math.max(days.length, 1);
|
||||
const width = Math.min(38, step * 0.62);
|
||||
const spansYears = days[0]?.date.slice(0, 4) !== days[days.length - 1]?.date.slice(0, 4);
|
||||
const scale = (value: number) => value / ceiling * (baseline - top);
|
||||
const keyDown = (event: KeyboardEvent<SVGGElement>, index: number) => {
|
||||
let next = index;
|
||||
if (event.key === "Enter" || event.key === " ") next = index;
|
||||
else if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
|
||||
else if (event.key === "ArrowRight") next = Math.min(days.length - 1, index + 1);
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = days.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
setSelectedDate(days[next].date);
|
||||
barsRef.current[next]?.focus();
|
||||
};
|
||||
|
||||
return <section className="cx-usage-card cx-usage-trend">
|
||||
<div className="cx-usage-card-heading"><div><h3>{copy.trend}</h3><p>{copy.trendHint}</p></div><BarChart3 size={18} aria-hidden="true" /></div>
|
||||
<Legend copy={copy} />
|
||||
{selected && <div className="cx-usage-day-summary" id={`${chartId}-selection`} aria-live="polite" aria-atomic="true">
|
||||
<div><strong>{dateLabel(selected.date, lang, true)}</strong>{selectedIndex === peakIndex && selected.totalTokens > 0 && <span>{copy.peak}</span>}</div>
|
||||
<div><TokenValue value={selected.totalTokens} lang={lang} /><small>{copy.tokens}</small></div>
|
||||
<p>{copy.input} {exact(selected.inputTokens, lang)} · {copy.cache} {exact(selected.cachedInputTokens, lang)} · {copy.output} {exact(selected.outputTokens, lang)}</p>
|
||||
</div>}
|
||||
<div className="cx-usage-chart-scroll">
|
||||
<svg className="cx-usage-chart" viewBox={`0 0 ${chartWidth} ${chartHeight}`} style={{ minWidth: Math.max(440, days.length * 11 + 56) }} role="group" aria-label={copy.chartLabel}>
|
||||
{[0, 0.5, 1].map((fraction) => <g key={fraction} aria-hidden="true">
|
||||
<line x1={left} x2={chartWidth - 12} y1={baseline - scale(ceiling * fraction)} y2={baseline - scale(ceiling * fraction)} className="cx-usage-gridline" />
|
||||
<text x={left - 9} y={baseline - scale(ceiling * fraction) + 3} textAnchor="end" className="cx-usage-axis">{formatUsageTokens(ceiling * fraction)}</text>
|
||||
</g>)}
|
||||
{days.map((day, index) => {
|
||||
const cached = Math.min(day.cachedInputTokens, day.inputTokens);
|
||||
const fresh = Math.max(0, day.inputTokens - cached);
|
||||
const inputHeight = scale(fresh);
|
||||
const cachedHeight = scale(cached);
|
||||
const outputHeight = scale(day.outputTokens);
|
||||
const x = left + step * index + (step - width) / 2;
|
||||
const showLabel = days.length <= 8 || index === days.length - 1 || index % Math.ceil(days.length / 7) === 0;
|
||||
const label = `${dateLabel(day.date, lang, true)}: ${copy.input} ${exact(day.inputTokens, lang)}, ${copy.cache} ${exact(cached, lang)}, ${copy.output} ${exact(day.outputTokens, lang)}, ${copy.totalColumn} ${exact(day.totalTokens, lang)}`;
|
||||
return <g key={day.date} ref={(element) => { barsRef.current[index] = element; }} className={`cx-usage-day${index === selectedIndex ? " cx-usage-day--selected" : ""}`} role="button" tabIndex={index === selectedIndex ? 0 : -1} aria-label={label} aria-pressed={index === selectedIndex} onMouseEnter={() => setSelectedDate(day.date)} onFocus={() => setSelectedDate(day.date)} onClick={() => setSelectedDate(day.date)} onKeyDown={(event) => keyDown(event, index)}>
|
||||
<title>{label}</title>
|
||||
<rect x={left + step * index} y={top - 4} width={step} height={baseline - top + 10} rx={4} className="cx-usage-chart-hit" />
|
||||
<rect x={x} y={baseline - inputHeight} width={width} height={inputHeight} className="cx-usage-swatch--input" />
|
||||
<rect x={x} y={baseline - inputHeight - cachedHeight} width={width} height={cachedHeight} className="cx-usage-swatch--cache" />
|
||||
<rect x={x} y={baseline - inputHeight - cachedHeight - outputHeight} width={width} height={outputHeight} className="cx-usage-swatch--output" />
|
||||
{day.totalTokens === 0 && <rect x={x} y={baseline - 2} width={width} height={2} rx={1} className="cx-usage-zero-bar" />}
|
||||
{showLabel && <text x={x + width / 2} y={baseline + 22} textAnchor="middle" className="cx-usage-axis" aria-hidden="true">{dateLabel(day.date, lang, spansYears)}</text>}
|
||||
</g>;
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<details className="cx-usage-daily-details"><summary>{copy.breakdown}<span>{days.length}</span></summary>
|
||||
<div className="cx-usage-table-scroll"><table className="cx-usage-table"><caption className="cx-usage-sr-only">{copy.breakdown}</caption><thead><tr><th>{copy.date}</th><th>{copy.input}</th><th>{copy.cache}</th><th>{copy.output}</th><th>{copy.totalColumn}</th></tr></thead><tbody>
|
||||
{days.map((day) => <tr key={day.date}><th scope="row">{dateLabel(day.date, lang, true)}</th><td>{exact(day.inputTokens, lang)}</td><td>{exact(day.cachedInputTokens, lang)}</td><td>{exact(day.outputTokens, lang)}</td><td>{exact(day.totalTokens, lang)}</td></tr>)}
|
||||
</tbody></table></div>
|
||||
</details>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ModelDistribution({ models, lang, copy, onModel }: { models: UsageModel[]; lang: Language; copy: UsageCopy; onModel: (model: string) => void }) {
|
||||
const rows = [...models].sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
const total = rows.reduce((sum, model) => sum + model.totalTokens, 0);
|
||||
const circumference = 2 * Math.PI * 58;
|
||||
let cumulative = 0;
|
||||
return <section className="cx-usage-card cx-usage-models">
|
||||
<div className="cx-usage-card-heading"><div><h3>{copy.modelTitle}</h3><p>{copy.modelHint}</p></div></div>
|
||||
<div className="cx-usage-donut-wrap"><svg className="cx-usage-donut" viewBox="0 0 160 160" role="img" aria-label={`${copy.modelTitle}: ${rows.map((row) => `${modelLabel(row.model, lang)} ${percentage(ratio(row.totalTokens, total))}`).join(", ")}`}>
|
||||
<circle cx="80" cy="80" r="58" fill="none" className="cx-usage-donut-track" strokeWidth="15" />
|
||||
{rows.map((row, index) => {
|
||||
const fraction = total > 0 ? row.totalTokens / total : 0;
|
||||
const offset = cumulative;
|
||||
cumulative += fraction;
|
||||
return <circle key={row.model} cx="80" cy="80" r="58" fill="none" strokeWidth="15" strokeDasharray={`${Math.max(0, fraction * circumference - (rows.length > 1 ? 3 : 0))} ${circumference}`} strokeDashoffset={-offset * circumference} transform="rotate(-90 80 80)" className={`cx-usage-model-color-${index % 5}`}><title>{modelLabel(row.model, lang)}: {exact(row.totalTokens, lang)} {copy.tokens}</title></circle>;
|
||||
})}
|
||||
</svg><div className="cx-usage-donut-label"><strong>{rows.length}</strong><span>{copy.modelCount}</span></div></div>
|
||||
<div className="cx-usage-model-list">
|
||||
{rows.length === 0 && <p className="cx-usage-muted">{copy.noModels}</p>}
|
||||
{rows.map((row, index) => <button key={row.model} type="button" className="cx-usage-model-row" onClick={() => onModel(row.model)} title={`${copy.modelFilter}: ${modelLabel(row.model, lang)}`}>
|
||||
<span className={`cx-usage-model-dot cx-usage-model-color-${index % 5}`} aria-hidden="true" />
|
||||
<span className="cx-usage-model-name">{modelLabel(row.model, lang)}</span>
|
||||
<span className="cx-usage-model-amount"><TokenValue value={row.totalTokens} lang={lang} /><small>{percentage(ratio(row.totalTokens, total))}</small></span>
|
||||
</button>)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function UsageStatisticsPage({ lang, configDir, active = true }: UsageStatisticsPageProps) {
|
||||
const copy = copyFor(lang);
|
||||
const [range, setRange] = useState<UsageRange>("7d");
|
||||
const [model, setModel] = useState("");
|
||||
const [record, setRecord] = useState<{ key: string; data: UsageStatistics } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const requestRef = useRef(0);
|
||||
const previousDir = useRef(configDir);
|
||||
const queryKey = JSON.stringify([configDir, range, model]);
|
||||
const data = record?.key === queryKey ? record.data : null;
|
||||
const load = useCallback(async (forceRefresh = false) => {
|
||||
if (!active) return;
|
||||
const request = ++requestRef.current;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await invoke<UsageStatistics>("get_usage_statistics", { configDir, range, model: model || null, forceRefresh });
|
||||
if (request === requestRef.current) setRecord({ key: queryKey, data: next });
|
||||
} catch (cause) {
|
||||
if (request === requestRef.current) setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
if (request === requestRef.current) setBusy(false);
|
||||
}
|
||||
}, [active, configDir, range, model, queryKey]);
|
||||
useEffect(() => {
|
||||
if (previousDir.current !== configDir) {
|
||||
previousDir.current = configDir;
|
||||
if (model) {
|
||||
setModel("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => { requestRef.current += 1; };
|
||||
}, [configDir, model, load]);
|
||||
const modelOptions = useMemo(() => {
|
||||
const available = record?.key && JSON.parse(record.key)[0] === configDir ? record.data.availableModels : [];
|
||||
return Array.from(new Set([...available, ...(model ? [model] : [])])).sort();
|
||||
}, [record, configDir, model]);
|
||||
const partial = data && (data.coverage.skippedFiles > 0 || data.coverage.truncated || data.coverage.warnings.length > 0);
|
||||
const hasUsage = data && data.totals.totalTokens > 0;
|
||||
const cacheRate = data ? ratio(data.totals.cachedInputTokens, data.totals.inputTokens) : 0;
|
||||
|
||||
return <div className="cx-usage" aria-busy={busy}>
|
||||
<header className="cx-usage-heading"><div><h3>{copy.title}</h3><p>{copy.description}</p></div><span className="cx-usage-local-badge"><Database size={12} aria-hidden="true" />{copy.source}</span></header>
|
||||
<div className="cx-usage-toolbar">
|
||||
<div className="cx-usage-range" role="group" aria-label={copy.range}>
|
||||
{(Object.keys(copy.ranges) as UsageRange[]).map((value) => <button key={value} type="button" aria-pressed={range === value} className={range === value ? "cx-usage-range-active" : ""} onClick={() => setRange(value)}>{copy.ranges[value]}</button>)}
|
||||
</div>
|
||||
<div className="cx-usage-toolbar-actions"><label className="cx-usage-model-select"><span className="cx-usage-sr-only">{copy.model}</span><select value={model} onChange={(event) => setModel(event.currentTarget.value)} aria-label={copy.model}><option value="">{copy.allModels}</option>{modelOptions.map((value) => <option key={value} value={value}>{modelLabel(value, lang)}</option>)}</select></label>
|
||||
<button type="button" className="cx-page-button cx-page-button--secondary cx-usage-refresh" disabled={busy} onClick={() => void load(true)}><RefreshCw size={14} className={busy ? "cx-page-spin" : ""} aria-hidden="true" />{busy ? copy.refreshing : copy.refresh}</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="cx-usage-notice cx-usage-notice--error" role="alert"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.stale}</p>}<p>{error}</p></div><button type="button" onClick={() => void load(true)} disabled={busy}>{copy.retry}</button></div>}
|
||||
{!data && !error && <div className="cx-usage-loading" role="status"><Loader2 size={27} className="cx-page-spin" aria-hidden="true" /><p>{copy.loading}</p><div className="cx-usage-loading-cards" aria-hidden="true"><span /><span /><span /></div></div>}
|
||||
{partial && <div className="cx-usage-notice cx-usage-notice--warning" role="status"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.partial}</strong><p>{copy.partialHint}</p>{data.coverage.warnings.length > 0 && <details><summary>{lang === "zh" ? "查看详情" : "View details"}</summary><ul>{data.coverage.warnings.map((warning, index) => <li key={`${index}-${warning}`}>{warning}</li>)}</ul></details>}</div></div>}
|
||||
{data && !hasUsage && <section className="cx-usage-empty"><div className="cx-usage-empty-visual" aria-hidden="true"><span /><span /><span /><span /><BarChart3 size={23} /></div><h3>{copy.noData}</h3><p>{copy.noDataDescription}</p>{(range !== "all" || model) && <button type="button" className="cx-page-button cx-page-button--secondary" onClick={() => { setRange("all"); setModel(""); }}>{copy.showAll}</button>}</section>}
|
||||
{data && hasUsage && <>
|
||||
<section className="cx-usage-summary" aria-label={copy.activity}>
|
||||
<article className="cx-usage-total">
|
||||
<div className="cx-usage-total-heading"><span><Activity size={16} aria-hidden="true" />{copy.total}</span><span className="cx-usage-period-label">{copy.ranges[range]}</span></div>
|
||||
<div className="cx-usage-total-value"><TokenValue value={data.totals.totalTokens} lang={lang} /><span>{copy.tokens}</span></div>
|
||||
<div className="cx-usage-total-caption"><span>{exact(data.totals.totalTokens, lang)} {copy.tokens}</span><span>{copy.totalHint}</span></div>
|
||||
<div className="cx-usage-composition" aria-hidden="true"><span className="cx-usage-swatch--input" style={{ width: `${ratio(data.totals.inputTokens - data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--cache" style={{ width: `${ratio(data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--output" style={{ width: `${ratio(data.totals.outputTokens, data.totals.totalTokens)}%` }} /></div>
|
||||
<div className="cx-usage-total-footer"><CalendarDays size={12} aria-hidden="true" /><span>{data.rangeStart ? `${dateLabel(data.rangeStart, lang, true)} – ${dateLabel(data.rangeEnd, lang, true)}` : copy.ranges.all}</span></div>
|
||||
</article>
|
||||
<div className="cx-usage-metrics">
|
||||
<Metric icon={ArrowDownLeft} label={copy.input} hint={copy.inputHint} accent="input"><TokenValue value={data.totals.inputTokens} lang={lang} /></Metric>
|
||||
<Metric icon={ArrowUpRight} label={copy.output} hint={<>{copy.reasoning} <TokenValue value={data.totals.reasoningTokens} lang={lang} /></>} accent="output"><TokenValue value={data.totals.outputTokens} lang={lang} /></Metric>
|
||||
<Metric icon={Database} label={copy.cacheRate} hint={<><TokenValue value={data.totals.cachedInputTokens} lang={lang} /> {copy.cached}</>} accent="cache"><span className="cx-usage-number">{percentage(cacheRate)}</span></Metric>
|
||||
<Metric icon={MessageSquare} label={copy.sessions} hint={copy.sessionsHint} accent="sessions"><TokenValue value={data.totals.sessionCount} lang={lang} /></Metric>
|
||||
</div>
|
||||
</section>
|
||||
<div className="cx-usage-charts"><UsageTrend days={data.days} lang={lang} copy={copy} /><ModelDistribution models={data.models} lang={lang} copy={copy} onModel={setModel} /></div>
|
||||
<section className="cx-usage-card cx-usage-sessions"><div className="cx-usage-card-heading"><div><h3>{copy.recent}<span className="cx-usage-count">{data.sessions.length}</span></h3><p>{copy.recentHint}</p></div><MessageSquare size={18} aria-hidden="true" /></div>
|
||||
<div className="cx-usage-table-scroll"><table className="cx-usage-table"><caption className="cx-usage-sr-only">{copy.recent}</caption><thead><tr><th>{copy.session}</th><th>{copy.model}</th><th>{copy.input}</th><th>{copy.output}</th><th>{copy.totalColumn}</th><th>{copy.lastActive}</th></tr></thead><tbody>{data.sessions.map((session) => <tr key={`${session.id}-${session.model}`}><th scope="row"><span className="cx-usage-session-name" title={sessionTitleLabel(session.title, lang)}>{sessionTitleLabel(session.title, lang)}</span></th><td><span className="cx-usage-session-model" title={modelLabel(session.model, lang)}>{modelLabel(session.model, lang)}</span></td><td><TokenValue value={session.inputTokens} lang={lang} /></td><td><TokenValue value={session.outputTokens} lang={lang} /></td><td className="cx-usage-cell-total"><TokenValue value={session.totalTokens} lang={lang} /></td><td className="cx-usage-cell-date">{timestampLabel(session.lastActiveAt, lang)}</td></tr>)}</tbody></table></div>
|
||||
</section>
|
||||
</>}
|
||||
{data && <footer className="cx-usage-footer"><div><Check size={12} aria-hidden="true" /><span>{copy.updated} {timestampLabel(data.refreshedAt, lang)} · {data.timezone} · {copy.scanned} {exact(data.coverage.scannedFiles, lang)} {copy.files}</span></div><p>{copy.notes}</p></footer>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { createContext, useContext, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
BarChart3,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell } from "../components/ui";
|
||||
import { UsageStatisticsPage } from "./UsageStatisticsPage";
|
||||
import "../styles/utility-pages.css";
|
||||
|
||||
export type UtilityLanguage = "zh" | "en";
|
||||
@@ -84,16 +91,39 @@ export type SettingsCopy = {
|
||||
recheckTitle: ReactNode;
|
||||
recheckDescription: ReactNode;
|
||||
recheckLabel: ReactNode;
|
||||
restartTitle: ReactNode;
|
||||
restartDescription: ReactNode;
|
||||
restartLabel: ReactNode;
|
||||
restartTargetLabel: ReactNode;
|
||||
restartConfirmTitle: ReactNode;
|
||||
restartConfirmDescription: ReactNode;
|
||||
restartCancelLabel: string;
|
||||
restartConfirmLabel: ReactNode;
|
||||
restartingLabel: ReactNode;
|
||||
};
|
||||
|
||||
export type SettingsPageProps = {
|
||||
lang: UtilityLanguage;
|
||||
configDir: string;
|
||||
active?: boolean;
|
||||
copy: SettingsCopy;
|
||||
onLanguageChange: (lang: UtilityLanguage) => void;
|
||||
onRecheck: () => void;
|
||||
onRestartCodex: () => Promise<boolean>;
|
||||
recheckBusy?: boolean;
|
||||
restartBusy?: boolean;
|
||||
};
|
||||
|
||||
// PageTransition keeps the previous content during its exit animation. Context
|
||||
// still propagates the current activity state so requests stop immediately,
|
||||
// while the retained usage component keeps its filters between tabs.
|
||||
const SettingsUsageActiveContext = createContext(false);
|
||||
|
||||
function SettingsUsagePanel({ lang, configDir }: Pick<SettingsPageProps, "lang" | "configDir">) {
|
||||
const active = useContext(SettingsUsageActiveContext);
|
||||
return <UsageStatisticsPage lang={lang} configDir={configDir} active={active} />;
|
||||
}
|
||||
|
||||
type SettingRowProps = {
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
@@ -118,65 +148,152 @@ function SettingRow({ icon: Icon, title, description, action }: SettingRowProps)
|
||||
|
||||
export function SettingsPage({
|
||||
lang,
|
||||
configDir,
|
||||
active = true,
|
||||
copy,
|
||||
onLanguageChange,
|
||||
onRecheck,
|
||||
onRestartCodex,
|
||||
recheckBusy = false,
|
||||
restartBusy = false,
|
||||
}: SettingsPageProps) {
|
||||
const [tab, setTab] = useState<"general" | "usage">("general");
|
||||
const [usageOpened, setUsageOpened] = useState(false);
|
||||
const tabId = useId();
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
const closeRestartConfirm = () => {
|
||||
if (!restartBusy) setRestartConfirmOpen(false);
|
||||
};
|
||||
const confirmRestart = async () => {
|
||||
if (restartBusy) return;
|
||||
if (await onRestartCodex()) setRestartConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="cx-utility cx-page cx-page--settings">
|
||||
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
|
||||
<div className="cx-page-settings-list">
|
||||
<SettingRow
|
||||
icon={Globe2}
|
||||
title={copy.languageTitle}
|
||||
description={copy.languageDescription}
|
||||
action={(
|
||||
<div className="cx-page-segmented" role="group" aria-label={String(copy.languageTitle)}>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "zh" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("zh")}
|
||||
aria-pressed={lang === "zh"}
|
||||
>
|
||||
{copy.chineseLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "en" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("en")}
|
||||
aria-pressed={lang === "en"}
|
||||
>
|
||||
{copy.englishLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={Sparkles}
|
||||
title={copy.productTitle}
|
||||
description={copy.productDescription}
|
||||
action={<span className="cx-page-value-pill">{copy.productValue}</span>}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={CheckCircle2}
|
||||
title={copy.recheckTitle}
|
||||
description={copy.recheckDescription}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={onRecheck}
|
||||
disabled={recheckBusy}
|
||||
>
|
||||
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
|
||||
{copy.recheckLabel}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<div className="cx-settings-tabs" role="tablist" aria-label={lang === "zh" ? "设置页面" : "Settings pages"}>
|
||||
{(["general", "usage"] as const).map((value, index) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : BarChart3;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); };
|
||||
return <button key={value} ref={(element) => { tabRefs.current[index] = element; }} type="button" role="tab" id={`${tabId}-${value}-tab`} aria-controls={`${tabId}-${value}-panel`} aria-selected={tab === value} tabIndex={tab === value ? 0 : -1} className="cx-settings-tab" onClick={select} onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
|
||||
tabRefs.current[next]?.click();
|
||||
tabRefs.current[next]?.focus();
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : (lang === "zh" ? "用量统计" : "Usage statistics")}</button>;
|
||||
})}
|
||||
</div>
|
||||
<SettingsUsageActiveContext.Provider value={active && tab === "usage"}>
|
||||
<PageTransition pageKey={`settings:${tab}`}>
|
||||
<div className="cx-settings-panel cx-page-settings-list" role="tabpanel" id={`${tabId}-general-panel`} aria-labelledby={`${tabId}-general-tab`} hidden={tab !== "general"}>
|
||||
<SettingRow
|
||||
icon={Globe2}
|
||||
title={copy.languageTitle}
|
||||
description={copy.languageDescription}
|
||||
action={(
|
||||
<div className="cx-page-segmented" role="group" aria-label={String(copy.languageTitle)}>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "zh" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("zh")}
|
||||
aria-pressed={lang === "zh"}
|
||||
>
|
||||
{copy.chineseLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={lang === "en" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
|
||||
onClick={() => onLanguageChange("en")}
|
||||
aria-pressed={lang === "en"}
|
||||
>
|
||||
{copy.englishLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={Sparkles}
|
||||
title={copy.productTitle}
|
||||
description={copy.productDescription}
|
||||
action={<span className="cx-page-value-pill">{copy.productValue}</span>}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={CheckCircle2}
|
||||
title={copy.recheckTitle}
|
||||
description={copy.recheckDescription}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={onRecheck}
|
||||
disabled={recheckBusy}
|
||||
>
|
||||
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
|
||||
{copy.recheckLabel}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
icon={Power}
|
||||
title={copy.restartTitle}
|
||||
description={copy.restartDescription}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
|
||||
{restartBusy ? copy.restartingLabel : copy.restartLabel}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-usage-panel`} aria-labelledby={`${tabId}-usage-tab`} hidden={tab !== "usage"}>
|
||||
{usageOpened && <SettingsUsagePanel lang={lang} configDir={configDir} />}
|
||||
</div>
|
||||
</PageTransition>
|
||||
</SettingsUsageActiveContext.Provider>
|
||||
|
||||
<ModalShell
|
||||
open={restartConfirmOpen}
|
||||
onClose={closeRestartConfirm}
|
||||
title={copy.restartConfirmTitle}
|
||||
description={copy.restartConfirmDescription}
|
||||
size="sm"
|
||||
closeLabel={copy.restartCancelLabel}
|
||||
closeOnBackdrop={!restartBusy}
|
||||
closeOnEscape={!restartBusy}
|
||||
showCloseButton={!restartBusy}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeRestartConfirm} disabled={restartBusy} data-initial-focus>
|
||||
{copy.restartCancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
|
||||
onClick={() => void confirmRestart()}
|
||||
disabled={restartBusy}
|
||||
>
|
||||
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="cx-page-restart-target">
|
||||
<Power size={18} aria-hidden="true" />
|
||||
<strong>{copy.restartTargetLabel}</strong>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
|
||||
import type { Lang } from "./types";
|
||||
|
||||
const STORAGE_KEY = "codexx.promptCategories.v1";
|
||||
const PROMPT_STORAGE_KEY = "codexx.promptCategories.v1";
|
||||
const STATE_VERSION = 1 as const;
|
||||
export const PROMPT_CATEGORY_NAME_MAX_LENGTH = 40;
|
||||
|
||||
@@ -19,7 +19,20 @@ type PromptCategoryState = {
|
||||
assignments: Record<string, string>;
|
||||
};
|
||||
|
||||
function defaultCategories(lang: Lang): PromptCategory[] {
|
||||
export type ManagedCategoryOptions = {
|
||||
storageKey: string;
|
||||
defaultCategories: (lang: Lang) => PromptCategory[];
|
||||
defaultCategoryForPrompt?: (promptKey: string) => string | undefined;
|
||||
};
|
||||
|
||||
export function defaultPromptCategoryForKey(promptKey: string) {
|
||||
const normalized = promptKey.trim().toLowerCase();
|
||||
if (normalized.includes("software-development-")) return "software-development";
|
||||
if (normalized.includes("writing-")) return "writing";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function defaultPromptCategories(lang: Lang): PromptCategory[] {
|
||||
return lang === "zh"
|
||||
? [
|
||||
{ id: "security-reverse", name: "破甲/逆向" },
|
||||
@@ -33,8 +46,14 @@ function defaultCategories(lang: Lang): PromptCategory[] {
|
||||
];
|
||||
}
|
||||
|
||||
function initialState(lang: Lang): PromptCategoryState {
|
||||
const categories = defaultCategories(lang);
|
||||
const PROMPT_CATEGORY_OPTIONS: ManagedCategoryOptions = {
|
||||
storageKey: PROMPT_STORAGE_KEY,
|
||||
defaultCategories: defaultPromptCategories,
|
||||
defaultCategoryForPrompt: defaultPromptCategoryForKey,
|
||||
};
|
||||
|
||||
function initialState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
|
||||
const categories = options.defaultCategories(lang);
|
||||
return {
|
||||
version: STATE_VERSION,
|
||||
categories,
|
||||
@@ -48,8 +67,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptCategoryState {
|
||||
const fallback = initialState(lang);
|
||||
export function sanitizePromptCategoryState(
|
||||
value: unknown,
|
||||
lang: Lang,
|
||||
options: ManagedCategoryOptions = PROMPT_CATEGORY_OPTIONS,
|
||||
): PromptCategoryState {
|
||||
const fallback = initialState(lang, options);
|
||||
if (!isRecord(value) || value.version !== STATE_VERSION || !Array.isArray(value.categories)) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -91,19 +114,19 @@ export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptC
|
||||
};
|
||||
}
|
||||
|
||||
function loadState(lang: Lang): PromptCategoryState {
|
||||
function loadState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return initialState(lang);
|
||||
return sanitizePromptCategoryState(JSON.parse(raw), lang);
|
||||
const raw = localStorage.getItem(options.storageKey);
|
||||
if (!raw) return initialState(lang, options);
|
||||
return sanitizePromptCategoryState(JSON.parse(raw), lang, options);
|
||||
} catch {
|
||||
return initialState(lang);
|
||||
return initialState(lang, options);
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state: PromptCategoryState) {
|
||||
function saveState(storageKey: string, state: PromptCategoryState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -122,12 +145,12 @@ function nextCategoryId(categories: PromptCategory[]) {
|
||||
return id;
|
||||
}
|
||||
|
||||
export function usePromptCategories(lang: Lang) {
|
||||
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang));
|
||||
export function useManagedCategories(lang: Lang, options: ManagedCategoryOptions) {
|
||||
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang, options));
|
||||
|
||||
React.useEffect(() => {
|
||||
saveState(state);
|
||||
}, [state]);
|
||||
saveState(options.storageKey, state);
|
||||
}, [options.storageKey, state]);
|
||||
|
||||
const categoryIds = React.useMemo(
|
||||
() => new Set(state.categories.map((category) => category.id)),
|
||||
@@ -137,9 +160,11 @@ export function usePromptCategories(lang: Lang) {
|
||||
const categoryForPrompt = React.useCallback(
|
||||
(promptKey: string) => {
|
||||
const assigned = state.assignments[promptKey];
|
||||
return assigned && categoryIds.has(assigned) ? assigned : state.defaultCategoryId;
|
||||
if (assigned && categoryIds.has(assigned)) return assigned;
|
||||
const inferred = options.defaultCategoryForPrompt?.(promptKey);
|
||||
return inferred && categoryIds.has(inferred) ? inferred : state.defaultCategoryId;
|
||||
},
|
||||
[categoryIds, state.assignments, state.defaultCategoryId],
|
||||
[categoryIds, options, state.assignments, state.defaultCategoryId],
|
||||
);
|
||||
|
||||
const setActiveCategoryId = React.useCallback((categoryId: string) => {
|
||||
@@ -210,11 +235,15 @@ export function usePromptCategories(lang: Lang) {
|
||||
if (!promptKey || !categoryIds.has(categoryId)) return;
|
||||
setState((current) => {
|
||||
const assignments = { ...current.assignments };
|
||||
if (categoryId === current.defaultCategoryId) delete assignments[promptKey];
|
||||
const inferred = options.defaultCategoryForPrompt?.(promptKey);
|
||||
const naturalCategoryId = inferred && categoryIds.has(inferred)
|
||||
? inferred
|
||||
: current.defaultCategoryId;
|
||||
if (categoryId === naturalCategoryId) delete assignments[promptKey];
|
||||
else assignments[promptKey] = categoryId;
|
||||
return { ...current, assignments };
|
||||
});
|
||||
}, [categoryIds]);
|
||||
}, [categoryIds, options]);
|
||||
|
||||
const forgetPrompt = React.useCallback((promptKey: string) => {
|
||||
if (!promptKey) return;
|
||||
@@ -238,3 +267,7 @@ export function usePromptCategories(lang: Lang) {
|
||||
forgetPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePromptCategories(lang: Lang) {
|
||||
return useManagedCategories(lang, PROMPT_CATEGORY_OPTIONS);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export type ProviderProfile = {
|
||||
baseUrl?: string | null;
|
||||
providerName?: string | null;
|
||||
model?: string | null;
|
||||
apiKey?: string | null;
|
||||
};
|
||||
|
||||
function normalizeProviderBaseUrl(value?: string | null) {
|
||||
const raw = (value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const credentials = parsed.username
|
||||
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
|
||||
: "";
|
||||
const path = parsed.pathname.replace(/\/+$/, "");
|
||||
return `${parsed.protocol.toLowerCase()}//${credentials}${parsed.host.toLowerCase()}${path}${parsed.search}`;
|
||||
} catch {
|
||||
return raw.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderName(value?: string | null) {
|
||||
return (value || "").trim().replace(/\s+/gu, " ").toLowerCase();
|
||||
}
|
||||
|
||||
export function providerProfilesMatch(left: ProviderProfile, right: ProviderProfile) {
|
||||
const leftUrl = normalizeProviderBaseUrl(left.baseUrl);
|
||||
if (!leftUrl || leftUrl !== normalizeProviderBaseUrl(right.baseUrl)) return false;
|
||||
if ((left.model || "").trim() !== (right.model || "").trim()) return false;
|
||||
|
||||
const leftKey = (left.apiKey || "").trim();
|
||||
const rightKey = (right.apiKey || "").trim();
|
||||
if (leftKey && rightKey) return leftKey === rightKey;
|
||||
return normalizeProviderName(left.providerName) === normalizeProviderName(right.providerName);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
type OrderedProviderRow = {
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export function orderProviderRows<
|
||||
Official extends OrderedProviderRow,
|
||||
Detected extends OrderedProviderRow,
|
||||
Local extends OrderedProviderRow,
|
||||
>(
|
||||
official: Official,
|
||||
detected: readonly Detected[],
|
||||
local: readonly Local[],
|
||||
): Array<Official | Detected | Local> {
|
||||
const rows: Array<Official | Detected | Local> = [official];
|
||||
if (!local.some((row) => row.isCurrent)) rows.push(...detected);
|
||||
rows.push(...local);
|
||||
return rows;
|
||||
}
|
||||
@@ -350,3 +350,11 @@
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
|
||||
.cx-dialog-spin {
|
||||
animation: cx-app-dialog-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cx-app-dialog-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -426,6 +426,26 @@
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.cx-state-loading {
|
||||
min-width: 0;
|
||||
min-height: 280px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cx-state-loading svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ui-accent);
|
||||
}
|
||||
|
||||
.cx-page-transition--exit,
|
||||
.cx-page-transition--enter {
|
||||
will-change: opacity, transform;
|
||||
|
||||
@@ -438,6 +438,10 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-providers-row-meta {
|
||||
color: #aeb3bc;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-providers-info-grid code {
|
||||
color: var(--cx-dark-muted);
|
||||
background: transparent;
|
||||
@@ -770,6 +774,20 @@
|
||||
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-note-field > span {
|
||||
color: #d8dbe0;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-note-field textarea {
|
||||
color: #e5e7eb;
|
||||
background: var(--cx-dark-surface-recessed);
|
||||
border-color: var(--cx-dark-border);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-skills-note-meta {
|
||||
color: var(--cx-dark-subtle);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .cx-prompts-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
|
||||
@@ -632,6 +632,52 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cx-providers-context-heading,
|
||||
.cx-providers-context-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
}
|
||||
|
||||
.cx-providers-context-heading {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cx-providers-context-heading > span {
|
||||
color: var(--cx-provider-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 660;
|
||||
}
|
||||
|
||||
.cx-providers-context-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cx-providers-context-control {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cx-providers-context-checkbox {
|
||||
min-height: 20px;
|
||||
color: var(--cx-provider-text);
|
||||
}
|
||||
|
||||
.cx-providers-context-hint,
|
||||
.cx-providers-context-error {
|
||||
max-width: 310px;
|
||||
color: var(--cx-provider-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cx-providers-context-error {
|
||||
color: #c85042;
|
||||
}
|
||||
|
||||
.cx-providers-preview {
|
||||
min-width: 0;
|
||||
max-height: 240px;
|
||||
@@ -702,15 +748,22 @@
|
||||
}
|
||||
|
||||
.cx-providers-auth-editor {
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
min-height: 420px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.cx-providers-toml-editor {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.cx-providers-editor-field + .cx-providers-editor-field {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cx-providers-form-actions {
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
@@ -719,6 +772,10 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cx-providers-official-actions {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.cx-providers-spin {
|
||||
animation: cx-providers-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,18 @@
|
||||
color: var(--cx-tool-text);
|
||||
}
|
||||
|
||||
.cx-skills-page,
|
||||
.cx-prompts-page--list {
|
||||
.cx-skills-page {
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cx-prompts-page--list {
|
||||
min-height: 100%;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cx-prompts-page--form {
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -323,10 +328,13 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy > strong {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #2a2e34;
|
||||
@@ -338,14 +346,38 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy--noted > strong {
|
||||
max-width: min(40%, 360px);
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.cx-skills-row-note {
|
||||
min-width: 0;
|
||||
flex: 1 1 180px;
|
||||
overflow: hidden;
|
||||
color: var(--cx-tool-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy .ui-status-badge {
|
||||
flex: 0 0 auto;
|
||||
max-width: min(30%, 220px);
|
||||
overflow: hidden;
|
||||
min-height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy .ui-status-badge > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cx-skills-row-control,
|
||||
.cx-prompts-row-actions {
|
||||
min-width: 58px;
|
||||
@@ -355,6 +387,67 @@
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.cx-skills-note-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cx-skills-note-modal .ui-modal__body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cx-skills-note-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cx-skills-note-field > span {
|
||||
color: #3e434b;
|
||||
font-size: 12px;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.cx-skills-note-field textarea {
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
resize: vertical;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #d8dce2;
|
||||
border-radius: 7px;
|
||||
color: #292d33;
|
||||
background: #ffffff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cx-skills-note-field textarea:focus {
|
||||
border-color: #8db2f5;
|
||||
outline: 2px solid rgba(37, 99, 235, 0.16);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.cx-skills-note-field textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cx-skills-note-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: #858b94;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cx-skills-note-meta span:last-child {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cx-skills-empty {
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
@@ -580,10 +673,6 @@
|
||||
border-color: #5944d5;
|
||||
}
|
||||
|
||||
.cx-prompts-heading > p {
|
||||
color: var(--cx-prompt-accent);
|
||||
}
|
||||
|
||||
.cx-prompts-mode-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -1074,7 +1163,7 @@
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1082,12 +1171,12 @@
|
||||
.cx-prompts-list {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
overflow: visible;
|
||||
padding: 0 8px 32px 0;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
@@ -1283,6 +1372,10 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.cx-prompts-form-panel-head .ui-status-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.cx-prompts-form-grid {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -1357,6 +1450,12 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cx-prompts-field input:read-only:not(:disabled) {
|
||||
cursor: default;
|
||||
color: #646971;
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.cx-prompts-field--content {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -1429,12 +1528,37 @@
|
||||
}
|
||||
|
||||
.cx-skills-row {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cx-skills-row-control {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
grid-row: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy--noted {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cx-skills-row-copy--noted > strong {
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cx-skills-row-note {
|
||||
flex-basis: 100%;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.cx-prompts-list {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
.cx-settings-tabs {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
padding: 4px;
|
||||
margin-bottom: 22px;
|
||||
border: 1px solid var(--cx-page-border);
|
||||
border-radius: 9px;
|
||||
background: var(--cx-page-status-neutral);
|
||||
}
|
||||
|
||||
.cx-settings-tab {
|
||||
min-height: 34px;
|
||||
min-width: 128px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--cx-page-muted);
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.cx-settings-tab:hover { color: var(--cx-page-text); background: var(--cx-page-surface-hover); }
|
||||
.cx-settings-tab[aria-selected="true"] { color: var(--cx-page-blue); border-color: var(--cx-page-border); background: var(--cx-page-surface); box-shadow: 0 1px 3px #2022260d; }
|
||||
.cx-settings-tab:focus-visible,
|
||||
.cx-usage button:focus-visible,
|
||||
.cx-usage select:focus-visible,
|
||||
.cx-usage summary:focus-visible { outline: 2px solid var(--cx-page-blue); outline-offset: 3px; }
|
||||
.cx-settings-panel[hidden] { display: none; }
|
||||
|
||||
.cx-usage {
|
||||
--cx-usage-input: #3b6fea;
|
||||
--cx-usage-cache: #96b9fa;
|
||||
--cx-usage-output: #25acb5;
|
||||
--cx-usage-hero: #eef4ff;
|
||||
--cx-usage-hero-border: #d9e5fd;
|
||||
--cx-usage-hero-text: #183c80;
|
||||
--cx-usage-warning: #8a601b;
|
||||
--cx-usage-warning-bg: #fffbef;
|
||||
--cx-usage-warning-border: #f0e2bd;
|
||||
--cx-usage-error: #ae3b43;
|
||||
--cx-usage-error-bg: #fff3f4;
|
||||
--cx-usage-error-border: #f0cfd2;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.cx-usage-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; }
|
||||
.cx-usage-heading h3 { margin: 0; font-size: 17px; line-height: 1.4; font-weight: 690; letter-spacing: -.02em; color: var(--cx-page-text); }
|
||||
.cx-usage-heading p { margin: 5px 0 0; color: var(--cx-page-muted); font-size: 12px; line-height: 1.6; }
|
||||
.cx-usage-local-badge { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; padding: 5px 8px; border: 1px solid var(--cx-page-border-soft); border-radius: 6px; color: var(--cx-page-muted); font-size: 10px; background: var(--cx-page-surface); }
|
||||
.cx-usage-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.cx-usage-range { display: inline-flex; gap: 3px; padding: 3px; border: 1px solid var(--cx-page-border); border-radius: 8px; background: var(--cx-page-surface); }
|
||||
.cx-usage-range button { min-height: 30px; padding: 0 14px; border: none; border-radius: 5px; background: transparent; color: var(--cx-page-muted); font-size: 11px; font-weight: 640; }
|
||||
.cx-usage-range button:hover { color: var(--cx-page-blue); background: var(--cx-page-surface-hover); }
|
||||
.cx-usage-range button.cx-usage-range-active { color: var(--cx-page-blue); background: var(--cx-page-blue-soft); }
|
||||
.cx-usage-toolbar-actions { min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.cx-usage-model-select { min-width: 0; display: block; }
|
||||
.cx-usage-model-select select { width: 170px; max-width: 100%; height: 38px; padding: 0 24px 0 11px; border: 1px solid var(--cx-page-border); border-radius: 7px; background: var(--cx-page-control); color: var(--cx-page-text); font-size: 11px; text-overflow: ellipsis; }
|
||||
.cx-usage .cx-usage-refresh { min-height: 38px; padding: 0 12px; font-size: 11px; white-space: nowrap; }
|
||||
.cx-usage-number { font-variant-numeric: tabular-nums; letter-spacing: -.025em; }
|
||||
.cx-usage-muted { color: var(--cx-page-muted); font-size: 12px; }
|
||||
|
||||
.cx-usage-summary { display: grid; grid-template-columns: minmax(220px, 1.05fr) minmax(0, 1.6fr); gap: 14px; }
|
||||
.cx-usage-total { display: flex; flex-direction: column; justify-content: center; min-width: 0; padding: 20px 22px 16px; border: 1px solid var(--cx-usage-hero-border); border-radius: 12px; background: var(--cx-usage-hero); color: var(--cx-usage-hero-text); }
|
||||
.cx-usage-total-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; font-weight: 660; }
|
||||
.cx-usage-total-heading > span:first-child { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.cx-usage-period-label { white-space: nowrap; padding: 4px 6px; border: 1px solid var(--cx-usage-hero-border); border-radius: 5px; font-size: 10px; font-weight: 560; }
|
||||
.cx-usage-total-value { display: flex; align-items: baseline; flex-wrap: wrap; column-gap: 9px; margin-top: 17px; }
|
||||
.cx-usage-total-value > .cx-usage-number { font-size: clamp(34px, 4.3vw, 47px); font-weight: 740; line-height: 1.2; letter-spacing: -.055em; overflow-wrap: anywhere; }
|
||||
.cx-usage-total-value > span:last-child { font-size: 12px; opacity: .75; }
|
||||
.cx-usage-total-caption { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; gap: 5px 9px; margin-top: 5px; font-size: 10px; line-height: 1.5; opacity: .76; font-variant-numeric: tabular-nums; }
|
||||
.cx-usage-total-caption > span { overflow-wrap: anywhere; }
|
||||
.cx-usage-composition { display: flex; width: 100%; height: 6px; margin-top: 19px; overflow: hidden; border-radius: 5px; background: var(--cx-page-border); }
|
||||
.cx-usage-composition > span { height: 100%; }
|
||||
.cx-usage-total-footer { display: flex; align-items: center; gap: 6px; margin-top: 12px; font-size: 10px; line-height: 1.5; opacity: .8; }
|
||||
.cx-usage-total-footer > svg { flex-shrink: 0; }
|
||||
.cx-usage-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
|
||||
.cx-usage-metric { min-width: 0; padding: 15px 17px 12px; border: 1px solid var(--cx-page-border); border-radius: 10px; background: var(--cx-page-surface); }
|
||||
.cx-usage-metric-label { display: flex; align-items: center; gap: 7px; color: var(--cx-page-muted); font-size: 11px; line-height: 1.5; }
|
||||
.cx-usage-metric-label > svg { color: var(--cx-page-blue); flex-shrink: 0; }
|
||||
.cx-usage-metric--output .cx-usage-metric-label > svg { color: var(--cx-usage-output); }
|
||||
.cx-usage-metric-value { margin-top: 7px; color: var(--cx-page-text); font-size: 26px; font-weight: 700; line-height: 1.2; overflow-wrap: anywhere; }
|
||||
.cx-usage-metric p { margin: 5px 0 0; color: var(--cx-page-muted); font-size: 10px; line-height: 1.5; }
|
||||
.cx-usage-metric p .cx-usage-number { font-weight: 600; }
|
||||
|
||||
.cx-usage-charts { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(210px, 1fr); align-items: start; gap: 14px; }
|
||||
.cx-usage-card { min-width: 0; border: 1px solid var(--cx-page-border); border-radius: 11px; background: var(--cx-page-surface); box-shadow: 0 3px 14px #1b233005; }
|
||||
.cx-usage-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 0; }
|
||||
.cx-usage-card-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--cx-page-text); font-size: 13px; font-weight: 690; line-height: 1.45; }
|
||||
.cx-usage-card-heading p { margin: 4px 0 0; color: var(--cx-page-muted); font-size: 10px; line-height: 1.6; }
|
||||
.cx-usage-card-heading > svg { flex-shrink: 0; margin-top: 1px; color: var(--cx-page-subtle); }
|
||||
.cx-usage-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 7px 14px; padding: 12px 18px 0; }
|
||||
.cx-usage-legend > span { display: inline-flex; align-items: center; gap: 5px; color: var(--cx-page-muted); font-size: 10px; }
|
||||
.cx-usage-legend i { display: block; width: 7px; height: 7px; border-radius: 2px; }
|
||||
.cx-usage-swatch--input { fill: var(--cx-usage-input); background: var(--cx-usage-input); }
|
||||
.cx-usage-swatch--cache { fill: var(--cx-usage-cache); background: var(--cx-usage-cache); }
|
||||
.cx-usage-swatch--output { fill: var(--cx-usage-output); background: var(--cx-usage-output); }
|
||||
.cx-usage-day-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 10px; margin: 15px 18px 3px; padding: 10px 12px; border: 1px solid var(--cx-page-border-soft); border-radius: 7px; background: var(--cx-page-surface-muted); }
|
||||
.cx-usage-day-summary > div { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.cx-usage-day-summary strong { color: var(--cx-page-text); font-size: 11px; font-weight: 610; }
|
||||
.cx-usage-day-summary > div:first-child > span { color: var(--cx-page-blue); font-size: 9px; }
|
||||
.cx-usage-day-summary .cx-usage-number { color: var(--cx-page-text); font-size: 15px; font-weight: 700; }
|
||||
.cx-usage-day-summary small { color: var(--cx-page-muted); font-size: 9px; }
|
||||
.cx-usage-day-summary p { grid-column: 1 / -1; margin: 0; color: var(--cx-page-muted); font-size: 9px; line-height: 1.65; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.cx-usage-chart-scroll { overflow-x: auto; padding: 4px 12px 0 5px; scrollbar-width: thin; }
|
||||
.cx-usage-chart { display: block; width: 100%; height: auto; max-height: 240px; }
|
||||
.cx-usage-gridline { stroke: var(--cx-page-border-soft); stroke-width: 1; stroke-dasharray: 3 4; }
|
||||
.cx-usage-axis { fill: var(--cx-page-muted); font-size: 10px; font-family: inherit; }
|
||||
.cx-usage-chart-hit { fill: transparent; }
|
||||
.cx-usage-day { cursor: pointer; outline: none; }
|
||||
.cx-usage-day--selected .cx-usage-chart-hit { fill: var(--cx-page-blue-soft); }
|
||||
.cx-usage-day:focus-visible .cx-usage-chart-hit { stroke: var(--cx-page-blue); stroke-width: 1.5; }
|
||||
.cx-usage-zero-bar { fill: var(--cx-page-border); }
|
||||
.cx-usage-daily-details { border-top: 1px solid var(--cx-page-border-soft); }
|
||||
.cx-usage-daily-details > summary { display: flex; align-items: center; gap: 8px; padding: 11px 18px; color: var(--cx-page-muted); font-size: 10px; cursor: pointer; }
|
||||
.cx-usage-daily-details > summary::before { content: "+"; color: var(--cx-page-subtle); font-size: 13px; }
|
||||
.cx-usage-daily-details[open] > summary::before { content: "−"; }
|
||||
.cx-usage-daily-details > summary::-webkit-details-marker { display: none; }
|
||||
.cx-usage-daily-details > summary > span { margin-left: auto; color: var(--cx-page-subtle); font-variant-numeric: tabular-nums; }
|
||||
.cx-usage-daily-details .cx-usage-table-scroll { max-height: 290px; }
|
||||
|
||||
.cx-usage-donut-wrap { position: relative; width: 150px; margin: 11px auto 5px; }
|
||||
.cx-usage-donut { display: block; width: 150px; height: 150px; }
|
||||
.cx-usage-donut-track { stroke: var(--cx-page-border-soft); }
|
||||
.cx-usage-donut-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.cx-usage-donut-label strong { color: var(--cx-page-text); font-size: 28px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||
.cx-usage-donut-label span { color: var(--cx-page-muted); font-size: 10px; margin-top: 4px; }
|
||||
.cx-usage-model-color-0 { stroke: #3b6fea; background: #3b6fea; }
|
||||
.cx-usage-model-color-1 { stroke: #25acb5; background: #25acb5; }
|
||||
.cx-usage-model-color-2 { stroke: #91b7fa; background: #91b7fa; }
|
||||
.cx-usage-model-color-3 { stroke: #8983cc; background: #8983cc; }
|
||||
.cx-usage-model-color-4 { stroke: #d5a159; background: #d5a159; }
|
||||
.cx-usage-model-list { display: grid; max-height: 212px; overflow-y: auto; padding: 0 11px 11px; scrollbar-width: thin; }
|
||||
.cx-usage-model-row { min-width: 0; display: grid; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 9px 7px; border: 0; border-radius: 6px; text-align: left; color: var(--cx-page-text); background: transparent; }
|
||||
.cx-usage-model-row:hover { background: var(--cx-page-surface-hover); }
|
||||
.cx-usage-model-dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.cx-usage-model-name { font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.cx-usage-model-amount { display: flex; flex-direction: column; gap: 2px; text-align: right; }
|
||||
.cx-usage-model-amount > span { font-size: 11px; font-weight: 650; }
|
||||
.cx-usage-model-amount > small { color: var(--cx-page-muted); font-size: 9px; font-variant-numeric: tabular-nums; }
|
||||
.cx-usage-count { padding: 2px 6px; border-radius: 5px; background: var(--cx-page-status-neutral); color: var(--cx-page-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.cx-usage-sessions .cx-usage-card-heading { padding-bottom: 14px; }
|
||||
.cx-usage-table-scroll { min-width: 0; overflow: auto; scrollbar-width: thin; border-radius: 0 0 11px 11px; }
|
||||
.cx-usage-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.cx-usage-table th { font-weight: 550; }
|
||||
.cx-usage-table thead th { position: sticky; top: 0; z-index: 1; background: var(--cx-page-surface-muted); color: var(--cx-page-muted); border-top: 1px solid var(--cx-page-border-soft); border-bottom: 1px solid var(--cx-page-border-soft); font-size: 9px; }
|
||||
.cx-usage-table td, .cx-usage-table th { padding: 11px 13px; line-height: 1.5; white-space: nowrap; }
|
||||
.cx-usage-table td:first-child, .cx-usage-table th:first-child { padding-left: 18px; }
|
||||
.cx-usage-table td:last-child, .cx-usage-table th:last-child { padding-right: 18px; }
|
||||
.cx-usage-table td { color: var(--cx-page-muted); }
|
||||
.cx-usage-table tbody tr + tr { border-top: 1px solid var(--cx-page-border-soft); }
|
||||
.cx-usage-table tbody tr:hover { background: var(--cx-page-surface-hover); }
|
||||
.cx-usage-table th:nth-child(n+3), .cx-usage-table td:nth-child(n+3) { text-align: right; }
|
||||
.cx-usage-table .cx-usage-cell-total { color: var(--cx-page-text); font-weight: 650; }
|
||||
.cx-usage-session-name { display: block; max-width: 190px; overflow: hidden; text-overflow: ellipsis; color: var(--cx-page-text); }
|
||||
.cx-usage-session-model { display: block; max-width: 150px; overflow: hidden; text-overflow: ellipsis; font-size: 9px; }
|
||||
.cx-usage-footer { color: var(--cx-page-subtle); font-size: 10px; line-height: 1.6; }
|
||||
.cx-usage-footer > div { display: flex; align-items: flex-start; gap: 5px; }
|
||||
.cx-usage-footer svg { flex-shrink: 0; margin-top: 2px; color: var(--cx-page-green); }
|
||||
.cx-usage-footer p { margin: 5px 0 0; font-size: 9px; }
|
||||
|
||||
.cx-usage-notice { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border: 1px solid; border-radius: 8px; font-size: 11px; line-height: 1.6; }
|
||||
.cx-usage-notice > svg { flex-shrink: 0; margin-top: 1px; }
|
||||
.cx-usage-notice > div { flex: 1; min-width: 0; }
|
||||
.cx-usage-notice strong { font-size: 11px; font-weight: 640; }
|
||||
.cx-usage-notice p { margin: 3px 0 0; overflow-wrap: anywhere; font-size: 10px; }
|
||||
.cx-usage-notice--warning { border-color: var(--cx-usage-warning-border); color: var(--cx-usage-warning); background: var(--cx-usage-warning-bg); }
|
||||
.cx-usage-notice--error { border-color: var(--cx-usage-error-border); color: var(--cx-usage-error); background: var(--cx-usage-error-bg); }
|
||||
.cx-usage-notice > button { flex-shrink: 0; margin-top: 1px; padding: 3px 7px; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 5px; font-size: 10px; }
|
||||
.cx-usage-notice details { margin-top: 4px; font-size: 10px; }
|
||||
.cx-usage-notice summary { width: fit-content; cursor: pointer; }
|
||||
.cx-usage-notice ul { margin: 4px 0 0; padding-left: 17px; overflow-wrap: anywhere; }
|
||||
.cx-usage-loading, .cx-usage-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 310px; padding: 35px; border: 1px dashed var(--cx-page-border); border-radius: 12px; background: var(--cx-page-surface); text-align: center; }
|
||||
.cx-usage-loading > svg { color: var(--cx-page-blue); }
|
||||
.cx-usage-loading > p { color: var(--cx-page-muted); font-size: 12px; margin: 14px 0 0; }
|
||||
.cx-usage-loading-cards { display: flex; gap: 10px; margin-top: 25px; }
|
||||
.cx-usage-loading-cards > span { width: 68px; height: 43px; background: var(--cx-page-status-neutral); border-radius: 7px; animation: cx-usage-pulse 1.5s ease-in-out infinite alternate; }
|
||||
.cx-usage-loading-cards > span:nth-child(2) { animation-delay: .15s; }
|
||||
.cx-usage-loading-cards > span:nth-child(3) { animation-delay: .3s; }
|
||||
.cx-usage-empty-visual { position: relative; display: flex; align-items: flex-end; justify-content: center; gap: 7px; width: 110px; height: 90px; padding: 23px 17px 10px; margin-bottom: 15px; border-radius: 13px; background: var(--cx-page-blue-soft); }
|
||||
.cx-usage-empty-visual > span { width: 12px; border-radius: 4px 4px 0 0; background: var(--cx-usage-cache); opacity: .7; }
|
||||
.cx-usage-empty-visual > span:nth-child(1) { height: 22px; }
|
||||
.cx-usage-empty-visual > span:nth-child(2) { height: 39px; }
|
||||
.cx-usage-empty-visual > span:nth-child(3) { height: 30px; }
|
||||
.cx-usage-empty-visual > span:nth-child(4) { height: 52px; background: var(--cx-usage-input); }
|
||||
.cx-usage-empty-visual > svg { position: absolute; right: -9px; bottom: -8px; box-sizing: content-box; padding: 7px; border: 3px solid var(--cx-page-surface); border-radius: 11px; color: var(--cx-page-blue); background: var(--cx-page-blue-soft); }
|
||||
.cx-usage-empty h3 { color: var(--cx-page-text); font-size: 15px; margin: 7px 0 0; }
|
||||
.cx-usage-empty p { max-width: 360px; margin: 9px 0 17px; font-size: 12px; line-height: 1.8; color: var(--cx-page-muted); }
|
||||
.cx-usage-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
:root[data-theme="dark"] .cx-usage { --cx-usage-input: #76a8ff; --cx-usage-cache: #405e8f; --cx-usage-output: #60c7c8; --cx-usage-hero: #2d3d58; --cx-usage-hero-border: #415777; --cx-usage-hero-text: #d8e7ff; --cx-usage-warning: #e1bd76; --cx-usage-warning-bg: #3b3529; --cx-usage-warning-border: #5c4e34; --cx-usage-error: #f0a4ab; --cx-usage-error-bg: #453037; --cx-usage-error-border: #66404a; }
|
||||
:root[data-theme="dark"] .cx-usage-model-color-0 { stroke: #76a8ff; background: #76a8ff; }
|
||||
:root[data-theme="dark"] .cx-usage-model-color-1 { stroke: #60c7c8; background: #60c7c8; }
|
||||
:root[data-theme="dark"] .cx-usage-model-color-2 { stroke: #526f9f; background: #526f9f; }
|
||||
:root[data-theme="dark"] .cx-usage-model-color-3 { stroke: #b0a9e8; background: #b0a9e8; }
|
||||
:root[data-theme="dark"] .cx-usage-model-color-4 { stroke: #d8b478; background: #d8b478; }
|
||||
|
||||
@keyframes cx-usage-pulse { from { opacity: .4; } to { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .cx-usage-loading-cards > span { animation: none; } }
|
||||
@media (max-width: 1050px) {
|
||||
.cx-usage-summary { grid-template-columns: minmax(210px, 1fr) minmax(0, 1.4fr); }
|
||||
.cx-usage-total { padding: 18px 16px 15px; }
|
||||
.cx-usage-metric { padding: 14px 13px 11px; }
|
||||
.cx-usage-charts { grid-template-columns: minmax(0, 1.6fr) minmax(200px, 1fr); }
|
||||
.cx-usage-local-badge { display: none; }
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.cx-usage-charts { grid-template-columns: minmax(0, 1fr); }
|
||||
.cx-usage-models { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; }
|
||||
.cx-usage-models .cx-usage-card-heading { grid-column: 1 / -1; }
|
||||
.cx-usage-model-list { padding-top: 12px; padding-right: 18px; max-height: 180px; }
|
||||
.cx-usage-summary { grid-template-columns: minmax(0, 1fr); }
|
||||
.cx-usage-total { padding: 18px 20px; }
|
||||
.cx-usage-total-value { margin-top: 11px; }
|
||||
.cx-usage-total-value > .cx-usage-number { font-size: 42px; }
|
||||
.cx-usage-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.cx-usage-metric { padding: 12px 10px; }
|
||||
.cx-usage-metric-value { font-size: 23px; }
|
||||
.cx-usage-metric-label { gap: 5px; font-size: 10px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.cx-settings-tabs { width: 100%; }
|
||||
.cx-settings-tab { min-width: 0; flex: 1; }
|
||||
.cx-usage-toolbar { align-items: stretch; }
|
||||
.cx-usage-range { width: 100%; }
|
||||
.cx-usage-range button { flex: 1; padding: 0 8px; }
|
||||
.cx-usage-toolbar-actions { width: 100%; justify-content: space-between; }
|
||||
.cx-usage-model-select { flex: 1; }
|
||||
.cx-usage-model-select select { width: 100%; }
|
||||
.cx-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cx-usage-metric { padding: 14px; }
|
||||
.cx-usage-day-summary { margin-left: 12px; margin-right: 12px; }
|
||||
.cx-usage-card-heading { padding-left: 14px; padding-right: 14px; }
|
||||
.cx-usage-models { grid-template-columns: 135px minmax(0, 1fr); }
|
||||
.cx-usage-donut-wrap { width: 130px; }
|
||||
.cx-usage-donut { width: 130px; height: 130px; }
|
||||
.cx-usage-model-list { padding-right: 10px; }
|
||||
.cx-usage-loading, .cx-usage-empty { padding: 28px 20px; }
|
||||
}
|
||||
@@ -232,6 +232,18 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cx-page-restart-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--cx-page-text);
|
||||
}
|
||||
|
||||
.cx-page-restart-target > svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--cx-page-blue);
|
||||
}
|
||||
|
||||
.cx-page-setting-copy p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--cx-page-muted);
|
||||
|
||||
@@ -33,6 +33,30 @@ export type SavedProvider = {
|
||||
requiresOpenaiAuth: boolean;
|
||||
};
|
||||
|
||||
export type OfficialProfileSummary = {
|
||||
id: string;
|
||||
providerName: string;
|
||||
model: string | null;
|
||||
hasAuth: boolean;
|
||||
isDefault: boolean;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export type OfficialProfileDetail = OfficialProfileSummary & {
|
||||
authJson: string;
|
||||
configText: string;
|
||||
};
|
||||
|
||||
export type OfficialProfileActionResult = ActionResult & {
|
||||
profile: OfficialProfileSummary;
|
||||
};
|
||||
|
||||
export type DuplicateProviderResult = {
|
||||
provider: SavedProvider;
|
||||
providers: SavedProvider[];
|
||||
activeProviderId: string | null;
|
||||
};
|
||||
|
||||
export type SavedPrompt = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -53,6 +77,15 @@ export type BuiltinPromptStatus = {
|
||||
syncIssue?: "catalog" | "content" | null;
|
||||
checkedAt?: string | null;
|
||||
message: string;
|
||||
customized: boolean;
|
||||
};
|
||||
|
||||
export type BuiltinPromptDetail = {
|
||||
id: string;
|
||||
filename: string;
|
||||
title: string;
|
||||
content: string;
|
||||
customized: boolean;
|
||||
};
|
||||
|
||||
export type BackupEntry = {
|
||||
@@ -74,12 +107,14 @@ export type CodexState = {
|
||||
officialAuthAvailable: boolean;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
isOfficialProvider: boolean;
|
||||
instructionFile?: string;
|
||||
instructionEnabled: boolean;
|
||||
instructionInjectionMode?: PromptInjectionMode;
|
||||
instructionTemplateKey?: string;
|
||||
agentsPath: string;
|
||||
activeSavedProviderId?: string;
|
||||
activeOfficialProfileId?: string | null;
|
||||
providers: ProviderSummary[];
|
||||
configText: string;
|
||||
authPreview?: unknown;
|
||||
@@ -94,6 +129,20 @@ export type ActionResult = {
|
||||
state: CodexState;
|
||||
};
|
||||
|
||||
export type OfficialConfigDraft = {
|
||||
authJson: string;
|
||||
configText: string;
|
||||
model?: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type OfficialAuthCandidate = {
|
||||
authJson: string;
|
||||
configText?: string | null;
|
||||
model?: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ImportResult = {
|
||||
imported: number;
|
||||
added: number;
|
||||
@@ -113,6 +162,11 @@ export type AboutInfo = {
|
||||
nativeUpdaterSupported: boolean;
|
||||
};
|
||||
|
||||
export type CodexDesktopRestartResult = {
|
||||
appName: string;
|
||||
wasRunning: boolean;
|
||||
};
|
||||
|
||||
export type ReleaseInfo = {
|
||||
status: "idle" | "checking" | "ok" | "error";
|
||||
latestVersion?: string;
|
||||
@@ -167,6 +221,7 @@ export type ManagedSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
note?: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
@@ -182,6 +237,7 @@ export type ManagedMcpServer = {
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
summary: string;
|
||||
note?: string | null;
|
||||
command?: string | null;
|
||||
url?: string | null;
|
||||
configJson: unknown;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type UsageRange = "today" | "7d" | "30d" | "all";
|
||||
|
||||
export type UsageTotals = {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
totalTokens: number;
|
||||
sessionCount: number;
|
||||
usageEventCount: number;
|
||||
};
|
||||
|
||||
export type UsageDay = UsageTotals & { date: string };
|
||||
export type UsageModel = UsageTotals & { model: string };
|
||||
export type UsageSession = UsageTotals & {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
startedAt: string;
|
||||
lastActiveAt: string;
|
||||
};
|
||||
|
||||
export type UsageStatistics = {
|
||||
totals: UsageTotals;
|
||||
days: UsageDay[];
|
||||
models: UsageModel[];
|
||||
sessions: UsageSession[];
|
||||
availableModels: string[];
|
||||
coverage: {
|
||||
scannedFiles: number;
|
||||
matchedSessions: number;
|
||||
skippedFiles: number;
|
||||
warnings: string[];
|
||||
truncated: boolean;
|
||||
};
|
||||
rangeStart: string | null;
|
||||
rangeEnd: string;
|
||||
refreshedAt: string;
|
||||
timezone: string;
|
||||
sourceDirectories: string[];
|
||||
};
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Renderer UI fixture
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
node apps/desktop/tests/serve-ui-fixture.mjs
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:1421/`. An optional positional port overrides `1421`.
|
||||
The server listens only on `127.0.0.1` and fails if that port is occupied.
|
||||
Stop with Ctrl+C.
|
||||
|
||||
This serves the real React renderer through Vite with a test-only script injected
|
||||
before the renderer loads. All Tauri IPC is intercepted. Commands use in-memory
|
||||
data, `.example.test` addresses and fake credentials. No native process, real
|
||||
Codex configuration, app database, or remote API is used. Unknown IPC commands
|
||||
fail explicitly. The fixture does not alter production entry points or Vite
|
||||
configuration.
|
||||
|
||||
## Delayed template detail and cross-page controls
|
||||
|
||||
1. Open **指令提示词**. Its automatic template sync remains pending.
|
||||
2. Click the edit button for **Fixture 内置模板**. The detail read also remains
|
||||
pending independently of the sync.
|
||||
3. Open **供应商** while both requests are pending. **新增供应商**, editing a
|
||||
supplier, and enabling **Fixture Saved Provider** should remain usable when
|
||||
the template-detail loading state is correctly scoped to its page.
|
||||
4. Use **Fixture:完成模板详情** after leaving the prompt page. Returning to
|
||||
**指令提示词** should not unexpectedly open an editor from that stale request.
|
||||
5. **Fixture:完成模板同步** resolves the pending catalog sync separately.
|
||||
|
||||
Before the UI fix, step 2 uses the shared `loading` flag and step 3 shows disabled
|
||||
supplier actions. The delayed detail is intentional even after backend reads are
|
||||
made fast, so this fixture can test the UI against a slow IPC response.
|
||||
|
||||
The panel sits in the lower-left corner. Click **Fixture:所有命令均为内存模拟**
|
||||
to collapse it when testing the sidebar. The appearance switch can also be
|
||||
focused and toggled with the Space key.
|
||||
|
||||
The fixture controls are:
|
||||
|
||||
- **Fixture:完成模板同步**
|
||||
- **Fixture:使模板同步失败**
|
||||
- **Fixture:完成模板详情**
|
||||
- **Fixture:使模板详情失败**
|
||||
|
||||
Each template control settles all currently pending requests of its kind.
|
||||
Clicking it with no pending request has no effect. Reloading resets pending
|
||||
requests and command counters, while provider and official-profile data survive
|
||||
in this fixture origin's `sessionStorage` (`codexx.fixture.backend.v2`). Use
|
||||
**Fixture:重置测试数据** for a clean starting model. The fixture also resets the
|
||||
renderer's language, startup-wizard marker, remembered provider and prompt
|
||||
categories in this fixture origin's localStorage.
|
||||
|
||||
## Multiple official logins
|
||||
|
||||
1. Reset the fixture. The default **OpenAI Official** profile has ID
|
||||
`openai-official` and fake OAuth identity `fixture-account-one`.
|
||||
2. In **供应商**, select **新增供应商**, change **供应商类型** to
|
||||
**官方 Codex 登录**, name it **第二账号**, and leave authentication empty.
|
||||
Save it. The current supplier must stay unchanged; the new profile has
|
||||
`hasAuth: false` in the fixture diagnostics.
|
||||
3. Enable **第二账号**. Diagnostics must show its new `activeOfficialProfileId`,
|
||||
with `currentOfficialAccount: null`. This models waiting for a Codex login.
|
||||
4. Click **Fixture:登录第二个官方账号**. This changes only the live in-memory
|
||||
official authentication to `fixture-account-two`, modeling an external Codex
|
||||
sign-in. It does not immediately modify the saved profile snapshot.
|
||||
5. Switch to **OpenAI Official**, then switch back to **第二账号**. The fake
|
||||
backend captures the previous live login before switching, so the diagnostics
|
||||
should alternate between `fixture-account-one` and `fixture-account-two`.
|
||||
Reload the browser and repeat to verify that the fixture model survives reload.
|
||||
6. To test explicit capture, activate an official profile, use the fixture login
|
||||
button, open another official profile's editor, click **读取当前官方登录**, and
|
||||
save. Authentication is copied into that chosen profile without switching the
|
||||
current supplier.
|
||||
7. Copy **OpenAI Official**. A named copy appears immediately in the list, with
|
||||
the same fake authentication and a distinct ID. No editor or confirmation
|
||||
dialog opens. Copy it again to check that names gain a numeric suffix. Open
|
||||
the copy's editor separately to rename it. Clearing its authentication and
|
||||
saving must leave the default profile unchanged.
|
||||
8. Delete a named profile. The fixture rejects deletion of the default or the
|
||||
currently active profile, so the frontend must switch to the default first
|
||||
when deleting an active named profile.
|
||||
|
||||
The official-login fixture implements `list_official_profiles`,
|
||||
`get_official_profile`, `save_official_profile`, `duplicate_official_profile`,
|
||||
`switch_official_profile`, and `delete_official_profile`. Saving a new/inactive
|
||||
profile does not switch; saving the active profile updates its live in-memory
|
||||
configuration. `authJson: null` preserves a saved profile's authentication; for
|
||||
the current official profile it captures the latest simulated live login.
|
||||
`authJson: ""` explicitly clears authentication. Both default and named profiles
|
||||
can be renamed. The additional
|
||||
controls are:
|
||||
|
||||
- **Fixture:登录第二个官方账号** — enabled only while an official profile is active.
|
||||
- **Fixture:重置测试数据** — removes the fixture session data and reloads.
|
||||
|
||||
For browser assertions, these actions are also exposed as
|
||||
`window.__CODEX_X_FIXTURE__.loginSecondOfficialAccount()` and `.reset()`.
|
||||
After editing `ui-runtime.js`, restart the fixture server: it reads the injected
|
||||
script once at startup.
|
||||
|
||||
## Immediate copies and 1M context
|
||||
|
||||
1. Copy **Fixture CC Switch** before switching providers. The detected original
|
||||
is preserved and a separate copy appears. The original stays current. Repeat
|
||||
with **Fixture Saved Provider** and an official profile: every click creates
|
||||
a unique name and ID, without opening an editor or confirmation dialog.
|
||||
2. Open either provider type's editor. Next to **config.toml**, enable
|
||||
**开启 1M 上下文窗口**. The draft gains top-level `model_context_window = 1000000`
|
||||
and, if absent, `model_auto_compact_token_limit = 900000`. Nothing is saved
|
||||
until the form's save button is clicked.
|
||||
3. Edit the context value manually to `1_000_000` and then `256000`; the checkbox
|
||||
should follow the draft. An existing compaction value such as `850000` must
|
||||
survive enabling and disabling. Turning on and off within the same edit
|
||||
restores the previous custom context value.
|
||||
4. Add an MCP table and test the toggle again. Those lines remain present. A
|
||||
`model_context_window` inside an MCP/profile table or a multiline string
|
||||
must not make the top-level checkbox checked. Invalid context-field types
|
||||
or an unfinished table header disable the checkbox and show an inline hint.
|
||||
5. Save, reopen the editor, and check that the 1M settings persist in the fake
|
||||
profile. Resetting the whole fixture intentionally removes them.
|
||||
|
||||
`update_codex_context_window` is a **limited mock**, supporting the simple
|
||||
line-based TOML used in these scenarios. It recognizes quoted context keys,
|
||||
integer separators, comments, tables and basic multiline strings. It does not
|
||||
validate arbitrary TOML and is not a substitute for the production `toml_edit`
|
||||
parser. The Rust `context_config` tests establish parser and mutation behavior.
|
||||
The same limitation applies to the fixture's provider form-to-TOML updates.
|
||||
|
||||
## Usage statistics
|
||||
|
||||
Open **设置 → 用量统计**. The fixture generates synthetic records relative to
|
||||
today across 40 days and three models. Input includes cached input; output
|
||||
includes reasoning; total is input plus output. The 7-day trend includes a day
|
||||
with no usage. **今天**, **近 7 天**, **近 30 天**, **全部** and the model filter
|
||||
actually aggregate different subsets. The daily and model sums must agree with
|
||||
the total cards. The recent-session list is capped at 10 while totals include
|
||||
all matching records. Rows show sample chat titles, not shortened session IDs.
|
||||
These sample rows represent main conversations with their subagent usage already
|
||||
included. Thread classification, recursive ownership, and replay deduplication
|
||||
are covered by the Rust usage/SQLite tests, not simulated by this UI fixture.
|
||||
Switch between **通用设置** and **用量统计** to check the shared fade-out/fade-in
|
||||
transition. Returning to usage keeps the selected date range and model.
|
||||
|
||||
Use the following panel controls, then click **刷新用量** (or **重试**):
|
||||
|
||||
- **Fixture:用量示例数据** restores the normal sample.
|
||||
- **Fixture:用量空数据** returns zero totals and no sessions/models.
|
||||
- **Fixture:用量读取失败** rejects the read. A first read shows the error view;
|
||||
after a successful read, a failed refresh should keep the last result with a
|
||||
failure notice.
|
||||
- **Fixture:用量部分数据** returns the sample with two synthetic skipped files,
|
||||
so the coverage notice is visible.
|
||||
|
||||
The selected mode appears in diagnostics as `usageMode`; it resets to `sample`
|
||||
on reload. Tests may call `window.__CODEX_X_FIXTURE__.setUsageMode(mode)`.
|
||||
`get_usage_statistics` accepts `configDir`, `range`, `model` and `forceRefresh`
|
||||
with the production response shape from `src/usageTypes.ts`. Both refresh
|
||||
paths return newly aggregated sample data; there is no filesystem scan or
|
||||
real usage cache in this fixture.
|
||||
|
||||
## Diagnostics and scope
|
||||
|
||||
The accessible **Fixture 命令记录** output (`#fixture-command-log`) displays JSON
|
||||
with pending counts, invocation counts, provider switches (including official
|
||||
profile IDs), saved provider IDs, official-profile summaries, fake account IDs,
|
||||
and the ordered command names. Authentication tokens are omitted from this
|
||||
diagnostic output. The same snapshot is available to browser assertions:
|
||||
|
||||
```js
|
||||
window.__CODEX_X_FIXTURE__.snapshot()
|
||||
```
|
||||
|
||||
Initially **Fixture CC Switch** is a detected current supplier, while
|
||||
**Fixture Saved Provider** is the only saved entry. The fake backend preserves
|
||||
the detected entry when switching away, allowing the renderer to show both
|
||||
entries and switch back. This models the desired backend response; it does **not**
|
||||
validate Rust persistence, credential resolution or database behavior. The same
|
||||
limitation applies to official-profile capture, independence, and storage: these
|
||||
responses are models for frontend verification. Use the Rust regression tests
|
||||
for backend guarantees. All persisted fixture credentials are synthetic and
|
||||
scoped to the local fixture origin and browser session.
|
||||
+812
@@ -0,0 +1,812 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
// Browser-only fixture. Every command below changes this in-memory model only.
|
||||
const clone = (value) => structuredClone(value);
|
||||
const codexDir = "/fixture/codex";
|
||||
const defaultOfficialId = "openai-official";
|
||||
const storageKey = "codexx.fixture.backend.v2";
|
||||
const counts = {};
|
||||
const switches = [];
|
||||
const commands = [];
|
||||
const pending = { sync: [], detail: [] };
|
||||
const callbacks = new Map();
|
||||
let nextCallback = 1;
|
||||
let output;
|
||||
let loginButton;
|
||||
let nextOfficialId = 1;
|
||||
let externalOfficialLogins = 0;
|
||||
let savedPrompts = [];
|
||||
let usageMode = "sample";
|
||||
|
||||
function officialToml(model) {
|
||||
return `model_provider = "openai"\nmodel = ${JSON.stringify(model || "fixture-official-model")}\n`;
|
||||
}
|
||||
|
||||
function officialAuth(account) {
|
||||
return JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: `fixture-only-id-token-${account}`,
|
||||
access_token: `fixture-only-access-token-${account}`,
|
||||
refresh_token: `fixture-only-refresh-token-${account}`,
|
||||
account_id: `fixture-account-${account}`,
|
||||
},
|
||||
last_refresh: "2026-09-08T00:00:00Z",
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
const officialProfiles = new Map([[defaultOfficialId, {
|
||||
id: defaultOfficialId,
|
||||
providerName: "OpenAI Official",
|
||||
model: "fixture-official-model",
|
||||
authJson: officialAuth("one"),
|
||||
configText: officialToml("fixture-official-model"),
|
||||
isDefault: true,
|
||||
}]]);
|
||||
|
||||
function toml(provider) {
|
||||
return [
|
||||
`model = ${JSON.stringify(provider.model)}`,
|
||||
`model_provider = ${JSON.stringify(provider.id)}`,
|
||||
"",
|
||||
`[model_providers.${provider.id}]`,
|
||||
`name = ${JSON.stringify(provider.providerName)}`,
|
||||
`base_url = ${JSON.stringify(provider.baseUrl)}`,
|
||||
`wire_api = ${JSON.stringify(provider.wireApi)}`,
|
||||
`experimental_bearer_token = ${JSON.stringify(provider.apiKey || "")}`,
|
||||
"requires_openai_auth = false",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function providerTomlDraft(provider) {
|
||||
const original = provider.tomlConfig?.trim();
|
||||
if (!original) return toml(provider);
|
||||
// Preserve the complete simple fixture draft, including 1M and MCP settings,
|
||||
// while reflecting form fields in its active provider section.
|
||||
const originalId = original.match(/^model_provider\s*=\s*"([^"]+)"/m)?.[1];
|
||||
let section = "";
|
||||
let foundSection = false;
|
||||
const values = {
|
||||
name: provider.providerName, base_url: provider.baseUrl,
|
||||
wire_api: provider.wireApi, experimental_bearer_token: provider.apiKey || "",
|
||||
requires_openai_auth: provider.requiresOpenaiAuth,
|
||||
};
|
||||
const lines = original.split("\n").map((line) => {
|
||||
const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
||||
if (header) {
|
||||
section = header[1];
|
||||
if (section === `model_providers.${originalId}`) {
|
||||
foundSection = true;
|
||||
return `[model_providers.${provider.id}]`;
|
||||
}
|
||||
}
|
||||
if (!section && /^model\s*=/.test(line)) return `model = ${JSON.stringify(provider.model)}`;
|
||||
if (!section && /^model_provider\s*=/.test(line)) return `model_provider = ${JSON.stringify(provider.id)}`;
|
||||
if (section === `model_providers.${originalId}`) {
|
||||
const key = line.match(/^([a-z_]+)\s*=/)?.[1];
|
||||
if (key && Object.hasOwn(values, key)) return `${key} = ${JSON.stringify(values[key])}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!foundSection) lines.push("", ...toml(provider).split("\n").slice(3));
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const detectedProvider = {
|
||||
id: "fixture-ccswitch",
|
||||
providerName: "Fixture CC Switch",
|
||||
baseUrl: "https://ccswitch.example.test/v1",
|
||||
model: "fixture-model-a",
|
||||
apiKey: "sk-fixture-only-a",
|
||||
wireApi: "responses",
|
||||
requiresOpenaiAuth: false,
|
||||
};
|
||||
detectedProvider.tomlConfig = toml(detectedProvider);
|
||||
let savedProviders = [{
|
||||
id: "fixture-saved",
|
||||
providerName: "Fixture Saved Provider",
|
||||
baseUrl: "https://saved.example.test/v1",
|
||||
model: "fixture-model-b",
|
||||
apiKey: "sk-fixture-only-b",
|
||||
wireApi: "responses",
|
||||
requiresOpenaiAuth: false,
|
||||
tomlConfig: "",
|
||||
}];
|
||||
const statuses = ["fixture-template", "fixture-template-other"].map((id, index) => ({
|
||||
id,
|
||||
filename: `${id}.md`,
|
||||
title: index === 0 ? "Fixture 内置模板" : "Fixture 第二模板",
|
||||
subtitle: "用于延迟请求与切页验证的本地假模板",
|
||||
badge: "Fixture",
|
||||
sourceUrl: `https://templates.example.test/${id}.md`,
|
||||
cached: true,
|
||||
updated: false,
|
||||
contentSource: "cache",
|
||||
syncIssue: null,
|
||||
checkedAt: null,
|
||||
message: "Fixture cached template",
|
||||
customized: false,
|
||||
}));
|
||||
const details = new Map(statuses.map((item) => [item.id, {
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
title: item.title,
|
||||
content: `# ${item.title}\n\nThis is a local UI fixture.\n`,
|
||||
customized: false,
|
||||
}]));
|
||||
let state = {
|
||||
codexDir,
|
||||
configPath: `${codexDir}/config.toml`,
|
||||
authPath: `${codexDir}/auth.json`,
|
||||
agentsPath: `${codexDir}/AGENTS.md`,
|
||||
configExists: true,
|
||||
authExists: true,
|
||||
officialAuthAvailable: true,
|
||||
model: detectedProvider.model,
|
||||
modelProvider: detectedProvider.id,
|
||||
isOfficialProvider: false,
|
||||
instructionEnabled: false,
|
||||
instructionInjectionMode: "append",
|
||||
instructionTemplateKey: undefined,
|
||||
activeSavedProviderId: undefined,
|
||||
activeOfficialProfileId: null,
|
||||
providers: [{
|
||||
id: detectedProvider.id,
|
||||
name: detectedProvider.providerName,
|
||||
baseUrl: detectedProvider.baseUrl,
|
||||
wireApi: "responses",
|
||||
requiresOpenaiAuth: false,
|
||||
isCurrent: true,
|
||||
}],
|
||||
configText: detectedProvider.tomlConfig,
|
||||
authText: JSON.stringify({ OPENAI_API_KEY: "sk-fixture-only-a" }),
|
||||
authPreview: { OPENAI_API_KEY: "sk-fixture-only-a" },
|
||||
};
|
||||
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem(storageKey) || "null");
|
||||
if (saved?.version === 2 && saved.state && Array.isArray(saved.savedProviders)
|
||||
&& Array.isArray(saved.savedPrompts) && Number.isSafeInteger(saved.nextOfficialId)
|
||||
&& Array.isArray(saved.officialProfiles)
|
||||
&& saved.officialProfiles.some((profile) => profile.id === defaultOfficialId)) {
|
||||
state = saved.state;
|
||||
savedProviders = saved.savedProviders;
|
||||
savedPrompts = saved.savedPrompts;
|
||||
nextOfficialId = saved.nextOfficialId;
|
||||
officialProfiles.clear();
|
||||
for (const profile of saved.officialProfiles) officialProfiles.set(profile.id, profile);
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or unavailable fixture storage leaves the fresh in-memory model intact.
|
||||
}
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify({
|
||||
version: 2,
|
||||
state,
|
||||
savedProviders,
|
||||
savedPrompts,
|
||||
nextOfficialId,
|
||||
officialProfiles: Array.from(officialProfiles.values()),
|
||||
}));
|
||||
} catch {
|
||||
// Tests still work in memory if sessionStorage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("codexx.lang", "zh");
|
||||
localStorage.setItem("codexx.startupWizardSeen", "1");
|
||||
localStorage.removeItem("codexx.activeProviderId");
|
||||
localStorage.removeItem("codexx.promptCategories.v1");
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
pendingSync: pending.sync.length,
|
||||
pendingDetail: pending.detail.length,
|
||||
commandCounts: clone(counts),
|
||||
providerSwitches: clone(switches),
|
||||
currentProvider: state.modelProvider,
|
||||
activeOfficialProfileId: state.activeOfficialProfileId,
|
||||
currentOfficialAccount: state.isOfficialProvider ? accountId(state.authText) : null,
|
||||
officialProfiles: Array.from(officialProfiles.values(), (profile) => ({
|
||||
...officialSummary(profile),
|
||||
accountId: accountId(profile.authJson),
|
||||
})),
|
||||
externalOfficialLogins,
|
||||
usageMode,
|
||||
savedProviderIds: savedProviders.map((provider) => provider.id),
|
||||
commands: clone(commands),
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (output) output.textContent = JSON.stringify(snapshot(), null, 2);
|
||||
if (loginButton) loginButton.disabled = !state.isOfficialProvider;
|
||||
}
|
||||
|
||||
function accountId(authJson) {
|
||||
try { return JSON.parse(authJson || "null")?.tokens?.account_id || null; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
function officialSummary(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
providerName: profile.providerName,
|
||||
model: profile.model || null,
|
||||
hasAuth: Boolean(profile.authJson.trim()),
|
||||
isDefault: profile.id === defaultOfficialId,
|
||||
isCurrent: state.isOfficialProvider && state.activeOfficialProfileId === profile.id,
|
||||
};
|
||||
}
|
||||
|
||||
function officialProfile(profileId) {
|
||||
const profile = officialProfiles.get(profileId);
|
||||
if (!profile) throw new Error(`Unknown fixture official profile: ${profileId}`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
function officialDetail(profile) {
|
||||
return clone({ ...officialSummary(profile), authJson: profile.authJson, configText: profile.configText });
|
||||
}
|
||||
|
||||
function captureCurrentOfficial() {
|
||||
if (!state.isOfficialProvider || !state.activeOfficialProfileId) return;
|
||||
const profile = officialProfile(state.activeOfficialProfileId);
|
||||
profile.authJson = state.authText || "";
|
||||
profile.configText = state.configText;
|
||||
profile.model = state.model || null;
|
||||
}
|
||||
|
||||
function loginSecondOfficialAccount() {
|
||||
if (!state.isOfficialProvider) return false;
|
||||
// Simulates Codex changing auth.json externally; the profile snapshot is saved later.
|
||||
state.authText = officialAuth("two");
|
||||
state.authPreview = JSON.parse(state.authText);
|
||||
state.authExists = true;
|
||||
externalOfficialLogins += 1;
|
||||
persist();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function defer(kind, result) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pending[kind].push({ resolve, reject, result });
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function settle(kind, fail) {
|
||||
for (const request of pending[kind].splice(0)) {
|
||||
if (fail) request.reject(new Error(`Fixture ${kind} failure`));
|
||||
else request.resolve(clone(request.result()));
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function saveProvider(provider) {
|
||||
const next = clone(provider);
|
||||
const existingIndex = savedProviders.findIndex((item) => item.id === next.id);
|
||||
if (existingIndex >= 0) savedProviders[existingIndex] = next;
|
||||
else savedProviders.push(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function uniqueCopyName(requested, names) {
|
||||
const base = requested?.trim() || "Fixture 副本";
|
||||
const used = new Set(names);
|
||||
let name = base;
|
||||
for (let suffix = 2; used.has(name); suffix += 1) name = `${base} ${suffix}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
function duplicateProvider(providerId, providerName) {
|
||||
if (providerId == null) preserveDetectedProvider();
|
||||
const sourceId = providerId || state.activeSavedProviderId || state.modelProvider;
|
||||
const source = savedProviders.find((provider) => provider.id === sourceId);
|
||||
if (!source || state.isOfficialProvider && providerId == null) throw new Error("Unknown fixture provider to duplicate");
|
||||
let suffix = 1;
|
||||
let id = `fixture-copy-${suffix}`;
|
||||
while (savedProviders.some((provider) => provider.id === id)) id = `fixture-copy-${++suffix}`;
|
||||
const provider = saveProvider({
|
||||
...clone(source),
|
||||
id,
|
||||
providerName: uniqueCopyName(providerName || `${source.providerName} 副本`, savedProviders.map((item) => item.providerName)),
|
||||
});
|
||||
// Capturing a detected row makes its existing identity explicit; copying never switches it.
|
||||
if (!state.isOfficialProvider && !state.activeSavedProviderId && sourceId === state.modelProvider) {
|
||||
state.activeSavedProviderId = sourceId;
|
||||
}
|
||||
return clone({ provider, providers: savedProviders, activeProviderId: state.activeSavedProviderId || null });
|
||||
}
|
||||
|
||||
function updateContextWindow({ configText, enabled, previousValues }) {
|
||||
// This is a deliberately limited model for the fixture's simple TOML drafts,
|
||||
// not a production TOML parser. Rust tests cover the real toml_edit behavior.
|
||||
const keys = ["model_context_window", "model_auto_compact_token_limit"];
|
||||
function scan(text) {
|
||||
const lines = text.split("\n");
|
||||
const fields = {};
|
||||
let firstTable = lines.length;
|
||||
let multiline = null;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (multiline) {
|
||||
if (line.includes(multiline)) multiline = null;
|
||||
continue;
|
||||
}
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
if (trimmed.startsWith("[")) {
|
||||
if (!/^\[.+\]\s*(?:#.*)?$/.test(trimmed)) throw new Error("Fixture TOML table syntax error");
|
||||
firstTable = Math.min(firstTable, index);
|
||||
continue;
|
||||
}
|
||||
for (const quote of ['"""', "'''"]) {
|
||||
if (line.split(quote).length % 2 === 0) multiline = quote;
|
||||
}
|
||||
if (index >= firstTable) continue;
|
||||
for (const key of keys) {
|
||||
const start = new RegExp(`^\\s*(?:${key}|"${key}"|'${key}')\\s*=`);
|
||||
if (!start.test(line)) continue;
|
||||
const match = line.match(new RegExp(`^(\\s*(?:${key}|"${key}"|'${key}')\\s*=\\s*)([+-]?[0-9][0-9_]*)(\\s*(?:#.*)?)$`));
|
||||
if (!match || fields[key]) throw new Error(`Fixture invalid integer setting: ${key}`);
|
||||
const value = Number(match[2].replaceAll("_", ""));
|
||||
if (!Number.isSafeInteger(value)) throw new Error(`Fixture unsupported integer: ${key}`);
|
||||
fields[key] = { index, value, prefix: match[1], suffix: match[3] };
|
||||
}
|
||||
}
|
||||
if (multiline) throw new Error("Fixture TOML multiline string syntax error");
|
||||
return { lines, fields, firstTable };
|
||||
}
|
||||
function set(text, key, value) {
|
||||
const { lines, fields, firstTable } = scan(text);
|
||||
const field = fields[key];
|
||||
if (field) {
|
||||
if (value == null) lines.splice(field.index, 1);
|
||||
else lines[field.index] = `${field.prefix}${value}${field.suffix}`;
|
||||
} else if (value != null) {
|
||||
lines.splice(Math.min(firstTable, lines.length - (lines.at(-1) === "" ? 1 : 0)), 0, `${key} = ${value}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
let next = configText;
|
||||
const original = scan(next).fields;
|
||||
const context = original[keys[0]]?.value ?? null;
|
||||
const compact = original[keys[1]]?.value ?? null;
|
||||
if (enabled === true) {
|
||||
next = set(next, keys[0], 1000000);
|
||||
if (compact == null) next = set(next, keys[1], 900000);
|
||||
} else if (enabled === false) {
|
||||
if (context === 1000000) next = set(next, keys[0], previousValues?.contextWindow ?? null);
|
||||
if (compact === (previousValues?.compactTokenLimit ?? 900000)) {
|
||||
next = set(next, keys[1], previousValues?.compactTokenLimit ?? null);
|
||||
}
|
||||
}
|
||||
const fields = scan(next).fields;
|
||||
return {
|
||||
configText: next,
|
||||
enabled: fields[keys[0]]?.value === 1000000,
|
||||
contextWindow: fields[keys[0]]?.value ?? null,
|
||||
compactTokenLimit: fields[keys[1]]?.value ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function preserveDetectedProvider() {
|
||||
// This stub models the expected Rust persistence behavior. It does not test Rust.
|
||||
if (state.modelProvider === detectedProvider.id
|
||||
&& !savedProviders.some((provider) => provider.id === detectedProvider.id)) {
|
||||
saveProvider(detectedProvider);
|
||||
}
|
||||
}
|
||||
|
||||
function action(message) {
|
||||
return clone({ ok: true, message, state });
|
||||
}
|
||||
|
||||
function usageStatistics({ range = "7d", model = null } = {}) {
|
||||
if (usageMode === "error") throw new Error("Fixture usage read failed; no real files were accessed");
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12);
|
||||
const dateKey = (value) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||
const dayAt = (daysAgo) => { const day = new Date(today); day.setDate(day.getDate() - daysAgo); return day; };
|
||||
const daysBack = ({ today: 0, "7d": 6, "30d": 29, all: 39 })[range];
|
||||
if (daysBack === undefined) throw new Error(`Unknown fixture usage range: ${range}`);
|
||||
const modelNames = ["gpt-5.5", "gpt-5.4-mini", "gpt-5.3-codex"];
|
||||
const titles = ["优化供应商切换", "设计用量统计", "完善本地回归验证"];
|
||||
const emptyTotals = () => ({ inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0, sessionCount: 0, usageEventCount: 0 });
|
||||
const sum = (rows) => rows.reduce((totals, row) => {
|
||||
for (const key of Object.keys(totals)) totals[key] += row[key];
|
||||
return totals;
|
||||
}, emptyTotals());
|
||||
const records = [];
|
||||
if (usageMode !== "empty") {
|
||||
for (let daysAgo = daysBack; daysAgo >= 0; daysAgo -= 1) {
|
||||
if (daysAgo === 3 || daysAgo === 12 || daysAgo === 25) continue;
|
||||
for (let modelIndex = 0; modelIndex < modelNames.length; modelIndex += 1) {
|
||||
if ((daysAgo + modelIndex) % 5 === 4) continue;
|
||||
const volume = [2.4, 1.1, 0.7][modelIndex] * (1 + ((daysAgo * 7 + modelIndex * 3) % 13) / 5);
|
||||
const inputTokens = Math.round(134500 * volume);
|
||||
const cachedInputTokens = Math.round(inputTokens * (0.42 + (daysAgo % 4) / 10));
|
||||
const outputTokens = Math.round(18400 * volume);
|
||||
const reasoningTokens = Math.round(outputTokens * 0.36);
|
||||
const date = dateKey(dayAt(daysAgo));
|
||||
const requestedEnd = new Date(`${date}T${15 + modelIndex}:32:00`);
|
||||
const lastActive = requestedEnd > now ? now : requestedEnd;
|
||||
const midnight = new Date(lastActive.getFullYear(), lastActive.getMonth(), lastActive.getDate());
|
||||
const startedAt = new Date(Math.max(midnight.getTime(), lastActive.getTime() - 80 * 60 * 1000));
|
||||
records.push({
|
||||
id: `fixture-usage-${date}-${modelIndex}`,
|
||||
title: `${titles[modelIndex]} · ${date.slice(5)}`,
|
||||
model: modelNames[modelIndex], date,
|
||||
startedAt: startedAt.toISOString(), lastActiveAt: lastActive.toISOString(),
|
||||
inputTokens, cachedInputTokens, outputTokens, reasoningTokens,
|
||||
totalTokens: inputTokens + outputTokens, sessionCount: 1, usageEventCount: 8 + daysAgo % 19,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const availableModels = Array.from(new Set(records.map((record) => record.model))).sort();
|
||||
const selected = records.filter((record) => !model || record.model === model);
|
||||
const days = [];
|
||||
for (let daysAgo = daysBack; daysAgo >= 0; daysAgo -= 1) {
|
||||
const date = dateKey(dayAt(daysAgo));
|
||||
days.push({ date, ...sum(selected.filter((record) => record.date === date)) });
|
||||
}
|
||||
return {
|
||||
totals: sum(selected), days,
|
||||
models: availableModels.map((name) => ({ model: name, ...sum(selected.filter((record) => record.model === name)) }))
|
||||
.filter((item) => item.totalTokens > 0).sort((left, right) => right.totalTokens - left.totalTokens),
|
||||
sessions: selected.slice().sort((left, right) => right.lastActiveAt.localeCompare(left.lastActiveAt)).slice(0, 10)
|
||||
.map(({ date: _date, ...session }) => session),
|
||||
availableModels,
|
||||
coverage: {
|
||||
scannedFiles: usageMode === "empty" ? 0 : 103,
|
||||
matchedSessions: selected.length,
|
||||
skippedFiles: usageMode === "partial" ? 2 : 0,
|
||||
warnings: usageMode === "partial" ? ["Fixture: two synthetic files could not be parsed"] : [],
|
||||
truncated: false,
|
||||
},
|
||||
rangeStart: range === "all" ? null : new Date(today.getFullYear(), today.getMonth(), today.getDate() - daysBack).toISOString(),
|
||||
rangeEnd: now.toISOString(), refreshedAt: now.toISOString(),
|
||||
timezone: `UTC${now.getTimezoneOffset() > 0 ? "-" : "+"}${String(Math.floor(Math.abs(now.getTimezoneOffset()) / 60)).padStart(2, "0")}:${String(Math.abs(now.getTimezoneOffset()) % 60).padStart(2, "0")}`,
|
||||
sourceDirectories: [`${codexDir}/sessions`, `${codexDir}/archived_sessions`],
|
||||
};
|
||||
}
|
||||
|
||||
function setUsageMode(mode) {
|
||||
if (!["sample", "empty", "error", "partial"].includes(mode)) throw new Error(`Unknown fixture usage mode: ${mode}`);
|
||||
usageMode = mode;
|
||||
render();
|
||||
}
|
||||
|
||||
function switchProvider(provider) {
|
||||
preserveDetectedProvider();
|
||||
captureCurrentOfficial();
|
||||
const from = state.modelProvider;
|
||||
const fromProfile = state.activeOfficialProfileId;
|
||||
state = {
|
||||
...state,
|
||||
model: provider.model,
|
||||
modelProvider: provider.id,
|
||||
isOfficialProvider: false,
|
||||
activeOfficialProfileId: null,
|
||||
activeSavedProviderId: provider.id,
|
||||
authExists: Boolean(provider.apiKey),
|
||||
configText: provider.tomlConfig || toml(provider),
|
||||
authText: JSON.stringify({ OPENAI_API_KEY: provider.apiKey }),
|
||||
providers: [{
|
||||
id: provider.id,
|
||||
name: provider.providerName,
|
||||
baseUrl: provider.baseUrl,
|
||||
wireApi: provider.wireApi,
|
||||
requiresOpenaiAuth: provider.requiresOpenaiAuth,
|
||||
isCurrent: true,
|
||||
}],
|
||||
};
|
||||
switches.push({ from, fromProfile, to: provider.id, toProfile: null });
|
||||
render();
|
||||
return action(`Fixture switched to ${provider.providerName}`);
|
||||
}
|
||||
|
||||
function switchOfficial(profileId = defaultOfficialId) {
|
||||
officialProfile(profileId);
|
||||
preserveDetectedProvider();
|
||||
captureCurrentOfficial();
|
||||
const profile = officialProfile(profileId);
|
||||
const from = state.modelProvider;
|
||||
const fromProfile = state.activeOfficialProfileId;
|
||||
state = {
|
||||
...state,
|
||||
modelProvider: "openai",
|
||||
model: profile.model || "fixture-official-model",
|
||||
isOfficialProvider: true,
|
||||
activeOfficialProfileId: profile.id,
|
||||
activeSavedProviderId: undefined,
|
||||
authExists: Boolean(profile.authJson),
|
||||
providers: [],
|
||||
configText: profile.configText,
|
||||
authText: profile.authJson,
|
||||
authPreview: profile.authJson ? JSON.parse(profile.authJson) : null,
|
||||
};
|
||||
switches.push({ from, fromProfile, to: "openai", toProfile: profile.id });
|
||||
render();
|
||||
return action(`Fixture switched to ${profile.providerName}`);
|
||||
}
|
||||
|
||||
function saveOfficial(input) {
|
||||
const providerName = input.providerName.trim();
|
||||
if (!providerName) throw new Error("Fixture official provider name is required");
|
||||
if (input.authJson?.trim()) JSON.parse(input.authJson);
|
||||
if (input.id) officialProfile(input.id);
|
||||
if (input.id && input.authJson == null) captureCurrentOfficial();
|
||||
const authJson = input.authJson == null
|
||||
? (input.id ? officialProfile(input.id).authJson : "")
|
||||
: input.authJson.trim();
|
||||
const id = input.id || `fixture-official-${nextOfficialId++}`;
|
||||
const profile = {
|
||||
id,
|
||||
providerName,
|
||||
model: input.model?.trim() || null,
|
||||
configText: input.configText?.trim() || officialToml(input.model),
|
||||
authJson,
|
||||
isDefault: id === defaultOfficialId,
|
||||
};
|
||||
officialProfiles.set(id, profile);
|
||||
if (state.isOfficialProvider && state.activeOfficialProfileId === id) {
|
||||
state.model = profile.model || "fixture-official-model";
|
||||
state.configText = profile.configText;
|
||||
state.authText = profile.authJson;
|
||||
state.authExists = Boolean(profile.authJson);
|
||||
state.authPreview = profile.authJson ? JSON.parse(profile.authJson) : null;
|
||||
}
|
||||
return clone({ ...action("Fixture official profile saved"), profile: officialSummary(profile) });
|
||||
}
|
||||
|
||||
function duplicateOfficial(profileId, providerName) {
|
||||
captureCurrentOfficial();
|
||||
const original = officialProfile(profileId);
|
||||
return saveOfficial({
|
||||
...clone(original), id: null,
|
||||
providerName: uniqueCopyName(providerName || `${original.providerName} 副本`, Array.from(officialProfiles.values(), (profile) => profile.providerName)),
|
||||
});
|
||||
}
|
||||
|
||||
async function invoke(command, args = {}) {
|
||||
counts[command] = (counts[command] || 0) + 1;
|
||||
commands.push(command);
|
||||
render();
|
||||
try {
|
||||
return await dispatch(command, args);
|
||||
} finally {
|
||||
persist();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(command, args) {
|
||||
switch (command) {
|
||||
case "get_codex_state": return clone(state);
|
||||
case "list_saved_providers": return clone(savedProviders);
|
||||
case "list_saved_prompts": return clone(savedPrompts);
|
||||
case "get_builtin_prompt_status": return clone(statuses);
|
||||
case "list_official_profiles":
|
||||
captureCurrentOfficial();
|
||||
return Array.from(officialProfiles.values(), (profile) => clone(officialSummary(profile)));
|
||||
case "get_official_profile":
|
||||
captureCurrentOfficial();
|
||||
return officialDetail(officialProfile(args.profileId));
|
||||
case "save_official_profile": return saveOfficial(args.input);
|
||||
case "duplicate_official_profile": return duplicateOfficial(args.profileId, args.providerName);
|
||||
case "switch_official_profile": return switchOfficial(args.profileId);
|
||||
case "delete_official_profile":
|
||||
officialProfile(args.profileId);
|
||||
if (args.profileId === defaultOfficialId) throw new Error("Cannot delete the fixture default official profile");
|
||||
if (state.isOfficialProvider && state.activeOfficialProfileId === args.profileId) {
|
||||
throw new Error("Switch away before deleting the current fixture official profile");
|
||||
}
|
||||
officialProfiles.delete(args.profileId);
|
||||
return undefined;
|
||||
case "get_about_info": return {
|
||||
appVersion: "0.3.15", codexVersion: "fixture-cli", codexDir,
|
||||
projectUrl: "https://project.example.test/", githubRepo: "fixture/example",
|
||||
nativeUpdaterSupported: false,
|
||||
};
|
||||
case "get_startup_diagnostics": return {
|
||||
codexDir, needsManualSelect: false, summary: "Fixture ready", items: [],
|
||||
};
|
||||
case "check_app_update": return {
|
||||
latestVersion: "0.3.15", htmlUrl: "https://releases.example.test/", hasUpdate: false,
|
||||
};
|
||||
case "plugin:updater|check": return null;
|
||||
case "refresh_builtin_prompts": return defer("sync", () => statuses);
|
||||
case "get_builtin_prompt_detail": {
|
||||
if (!details.has(args.templateId)) throw new Error("Unknown fixture template");
|
||||
return defer("detail", () => details.get(args.templateId));
|
||||
}
|
||||
case "save_builtin_prompt_override": {
|
||||
const detail = details.get(args.templateId);
|
||||
if (!detail) throw new Error("Unknown fixture template");
|
||||
detail.content = args.content;
|
||||
detail.customized = true;
|
||||
statuses.find((item) => item.id === args.templateId).customized = true;
|
||||
return clone(detail);
|
||||
}
|
||||
case "enable_instruction_template":
|
||||
state.instructionEnabled = true;
|
||||
state.instructionTemplateKey = `builtin:${args.templateId}`;
|
||||
state.instructionInjectionMode = args.injectionMode;
|
||||
return action("Fixture prompt enabled");
|
||||
case "disable_instruction":
|
||||
case "disable_external_instruction":
|
||||
state.instructionEnabled = false;
|
||||
state.instructionTemplateKey = undefined;
|
||||
return action("Fixture prompt disabled");
|
||||
case "save_prompt":
|
||||
savedPrompts = [...savedPrompts.filter((prompt) => prompt.id !== args.prompt.id), clone(args.prompt)];
|
||||
return clone(args.prompt);
|
||||
case "delete_saved_prompt":
|
||||
savedPrompts = savedPrompts.filter((prompt) => prompt.id !== args.id);
|
||||
return undefined;
|
||||
case "enable_saved_prompt":
|
||||
state.instructionEnabled = true;
|
||||
state.instructionTemplateKey = `saved:${args.id}`;
|
||||
return action("Fixture saved prompt enabled");
|
||||
case "build_provider_toml_draft": return providerTomlDraft(args.provider);
|
||||
case "update_codex_context_window": return updateContextWindow(args);
|
||||
case "get_usage_statistics": return usageStatistics(args);
|
||||
case "duplicate_provider": return duplicateProvider(args.providerId, args.providerName);
|
||||
case "save_provider": return saveProvider(args.provider);
|
||||
case "save_active_provider": return switchProvider(saveProvider(args.provider));
|
||||
case "switch_provider": {
|
||||
const input = args.input;
|
||||
return switchProvider(savedProviders.find((provider) => provider.id === input.providerId) || {
|
||||
id: input.providerId, providerName: input.providerName,
|
||||
baseUrl: input.baseUrl, model: input.model, apiKey: input.apiKey,
|
||||
wireApi: input.wireApi, requiresOpenaiAuth: input.requiresOpenaiAuth,
|
||||
});
|
||||
}
|
||||
case "save_provider_toml_config": {
|
||||
const provider = savedProviders.find((item) => item.id === args.providerId);
|
||||
if (!provider) throw new Error("Unknown fixture provider");
|
||||
return switchProvider({ ...provider, tomlConfig: args.input.configText, apiKey: args.input.apiKey });
|
||||
}
|
||||
case "switch_official_provider":
|
||||
case "restore_official_provider": return switchOfficial();
|
||||
case "reset_official_provider":
|
||||
saveOfficial({ id: defaultOfficialId, providerName: officialProfile(defaultOfficialId).providerName, model: "fixture-official-model", authJson: "", configText: "" });
|
||||
return switchOfficial();
|
||||
case "save_official_config": {
|
||||
const input = args.input || args;
|
||||
return saveOfficial({ ...input, id: defaultOfficialId, providerName: officialProfile(defaultOfficialId).providerName });
|
||||
}
|
||||
case "get_official_config_draft": {
|
||||
captureCurrentOfficial();
|
||||
const profile = officialProfile(defaultOfficialId);
|
||||
return { authJson: profile.authJson, configText: profile.configText, model: profile.model, source: "fixture" };
|
||||
}
|
||||
case "read_ccswitch_official_auth": return {
|
||||
authJson: officialAuth("one"), configText: officialToml("fixture-official-model"),
|
||||
model: "fixture-official-model", source: "fixture",
|
||||
};
|
||||
case "delete_saved_provider":
|
||||
savedProviders = savedProviders.filter((provider) => provider.id !== args.id);
|
||||
render();
|
||||
return undefined;
|
||||
case "import_ccswitch_codex_providers":
|
||||
preserveDetectedProvider();
|
||||
return { imported: 1, added: 1, updated: 0, merged: 0, skipped: 0, warnings: [], providers: clone(savedProviders) };
|
||||
case "test_provider_connection": return { ok: true, status: 200, message: "Fixture connection OK", durationMs: 1 };
|
||||
case "fetch_provider_models": return { models: [{ id: "fixture-model-a" }, { id: "fixture-model-b" }], status: 200, durationMs: 1 };
|
||||
case "get_session_sync_status": return {
|
||||
codexDir, targetProvider: state.modelProvider, rolloutFiles: 0, sessionMetaCount: 0,
|
||||
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 0,
|
||||
topLevelThreads: 0, subagentThreads: 0, mismatchedThreads: 0, mismatchedSessions: 0,
|
||||
needsSync: false, scanComplete: true, scanFailures: [], warnings: [], sessions: [],
|
||||
};
|
||||
case "get_skills_mcp_state": return {
|
||||
codexDir, codexSkillsDir: `${codexDir}/skills`, disabledSkillsDir: `${codexDir}/disabled-skills`,
|
||||
skills: [], mcpServers: [], warnings: [],
|
||||
};
|
||||
case "open_url": return undefined;
|
||||
default: throw new Error(`Fixture has no handler for command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke,
|
||||
transformCallback(callback, once = false) {
|
||||
const id = nextCallback++;
|
||||
callbacks.set(id, (value) => {
|
||||
if (once) callbacks.delete(id);
|
||||
callback(value);
|
||||
});
|
||||
return id;
|
||||
},
|
||||
unregisterCallback(id) { callbacks.delete(id); },
|
||||
runCallback(id, value) { callbacks.get(id)?.(value); },
|
||||
};
|
||||
window.__CODEX_X_FIXTURE__ = {
|
||||
snapshot,
|
||||
loginSecondOfficialAccount,
|
||||
setUsageMode,
|
||||
completeSync: () => settle("sync", false),
|
||||
failSync: () => settle("sync", true),
|
||||
completeDetail: () => settle("detail", false),
|
||||
failDetail: () => settle("detail", true),
|
||||
reset() {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
window.location.reload();
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const panel = document.createElement("aside");
|
||||
panel.setAttribute("aria-label", "Fixture 控制面板");
|
||||
panel.style.cssText = "position:fixed;left:8px;bottom:8px;z-index:2147483647;width:224px;max-width:calc(100vw - 16px);box-sizing:border-box;background:#fff;color:#111;border:2px solid #1463d6;border-radius:8px;padding:8px;font:12px monospace;box-shadow:0 2px 8px #0003";
|
||||
const panelBody = document.createElement("details");
|
||||
panelBody.open = true;
|
||||
const label = document.createElement("summary");
|
||||
label.textContent = "Fixture:所有命令均为内存模拟";
|
||||
panelBody.append(label);
|
||||
panel.append(panelBody);
|
||||
const controls = document.createElement("div");
|
||||
controls.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px";
|
||||
for (const [text, kind, fail] of [
|
||||
["Fixture:完成模板同步", "sync", false],
|
||||
["Fixture:使模板同步失败", "sync", true],
|
||||
["Fixture:完成模板详情", "detail", false],
|
||||
["Fixture:使模板详情失败", "detail", true],
|
||||
]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = text;
|
||||
button.style.cssText = "font:inherit;padding:5px;border:1px solid #aaa;border-radius:4px;background:#eee;color:#111;cursor:pointer";
|
||||
button.addEventListener("click", () => settle(kind, fail));
|
||||
controls.append(button);
|
||||
}
|
||||
loginButton = document.createElement("button");
|
||||
loginButton.type = "button";
|
||||
loginButton.textContent = "Fixture:登录第二个官方账号";
|
||||
loginButton.style.cssText = "font:inherit;padding:5px;border:1px solid #aaa;border-radius:4px;background:#eee;color:#111;cursor:pointer";
|
||||
loginButton.addEventListener("click", loginSecondOfficialAccount);
|
||||
controls.append(loginButton);
|
||||
for (const [text, mode] of [
|
||||
["Fixture:用量示例数据", "sample"],
|
||||
["Fixture:用量空数据", "empty"],
|
||||
["Fixture:用量读取失败", "error"],
|
||||
["Fixture:用量部分数据", "partial"],
|
||||
]) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = text;
|
||||
button.style.cssText = loginButton.style.cssText;
|
||||
button.addEventListener("click", () => setUsageMode(mode));
|
||||
controls.append(button);
|
||||
}
|
||||
const resetButton = document.createElement("button");
|
||||
resetButton.type = "button";
|
||||
resetButton.textContent = "Fixture:重置测试数据";
|
||||
resetButton.style.cssText = loginButton.style.cssText;
|
||||
resetButton.addEventListener("click", () => window.__CODEX_X_FIXTURE__.reset());
|
||||
controls.append(resetButton);
|
||||
panelBody.append(controls);
|
||||
const diagnostics = document.createElement("details");
|
||||
diagnostics.open = false;
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "Fixture:命令次数与供应商切换记录";
|
||||
output = document.createElement("pre");
|
||||
output.id = "fixture-command-log";
|
||||
output.setAttribute("aria-label", "Fixture 命令记录");
|
||||
output.style.cssText = "white-space:pre-wrap;max-height:135px;overflow:auto;margin:6px 0 0";
|
||||
diagnostics.append(summary, output);
|
||||
panelBody.append(diagnostics);
|
||||
document.body.append(panel);
|
||||
render();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { providerProfilesMatch } from "../src/providerProfiles.ts";
|
||||
|
||||
const saved = {
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
providerName: "Example API",
|
||||
model: "gpt-5.6",
|
||||
apiKey: "sk-saved",
|
||||
};
|
||||
|
||||
test("remembered provider still matches when the live key is unavailable", () => {
|
||||
assert.equal(providerProfilesMatch(saved, {
|
||||
baseUrl: "https://API.EXAMPLE.com/v1",
|
||||
providerName: " example api ",
|
||||
model: "gpt-5.6",
|
||||
apiKey: "",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("different models or two different explicit keys do not match", () => {
|
||||
assert.equal(providerProfilesMatch(saved, { ...saved, model: "deepseek-v3" }), false);
|
||||
assert.equal(providerProfilesMatch(saved, { ...saved, apiKey: "sk-other" }), false);
|
||||
});
|
||||
|
||||
test("matching explicit credentials identify the same profile even after a rename", () => {
|
||||
assert.equal(providerProfilesMatch(saved, {
|
||||
...saved,
|
||||
providerName: "Renamed copy",
|
||||
}), true);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { orderProviderRows } from "../src/providerRowOrder.ts";
|
||||
|
||||
const official = { id: "official", isCurrent: false };
|
||||
const detected = { id: "detected", isCurrent: true };
|
||||
|
||||
test("a copied provider is last while the selected saved ID stays current", () => {
|
||||
const rows = orderProviderRows(official, [detected], [
|
||||
{ id: "older", isCurrent: false },
|
||||
{ id: "selected", isCurrent: true },
|
||||
{ id: "copy", isCurrent: false },
|
||||
]);
|
||||
|
||||
assert.deepEqual(rows.map((row) => row.id), ["official", "older", "selected", "copy"]);
|
||||
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["selected"]);
|
||||
});
|
||||
|
||||
test("an unresolved detected current remains visible before saved rows", () => {
|
||||
const rows = orderProviderRows(official, [detected], [
|
||||
{ id: "original", isCurrent: false },
|
||||
{ id: "copy", isCurrent: false },
|
||||
]);
|
||||
|
||||
assert.deepEqual(rows.map((row) => row.id), ["official", "detected", "original", "copy"]);
|
||||
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["detected"]);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { createServer } from "vite";
|
||||
|
||||
// This renderer-only server never starts Tauri or connects to the real Codex store.
|
||||
// Run: node apps/desktop/tests/serve-ui-fixture.mjs [port]
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
const port = Number(process.argv[2] || 1421);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
||||
throw new Error("Fixture port must be an integer between 1024 and 65535");
|
||||
}
|
||||
const fixture = await readFile(new URL("./fixtures/ui-runtime.js", import.meta.url), "utf8");
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: false,
|
||||
clearScreen: false,
|
||||
plugins: [
|
||||
{
|
||||
name: "codex-x-ui-fixture",
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler: () => [{ tag: "script", children: fixture, injectTo: "head-prepend" }],
|
||||
},
|
||||
},
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
strictPort: true,
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
headers: {
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:*; img-src 'self' data:; font-src 'self' data:; object-src 'none'; frame-src 'none'; base-uri 'none'",
|
||||
},
|
||||
},
|
||||
});
|
||||
await server.listen();
|
||||
console.log(`Fixture ready: http://127.0.0.1:${port}/`);
|
||||
console.log("All IPC is mocked. Use the Fixture controls to resolve pending template requests.");
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.once(signal, async () => {
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
# Codex-X 项目维护日志
|
||||
|
||||
本文件记录面向维护者的根因、设计决策、验证依据和遗留风险。它与
|
||||
`CHANGELOG.md` 分工如下:
|
||||
|
||||
- `CHANGELOG.md` 面向用户,记录每个发布版本可感知的新增、调整和修复。
|
||||
- 本文件面向开发维护,解释为什么修改、哪些约束不能破坏,以及如何验证。
|
||||
|
||||
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
|
||||
|
||||
## 2026-09-08:子代理用量归属主会话
|
||||
|
||||
- 根因:用量聚合使用物理线程 ID 作为会话 ID,未接入会话管理的子代理分类与父子关系,导致内部线程
|
||||
出现在最近会话并增加活跃会话数。
|
||||
- 复用 `sqlite_subagent_thread_ids` 与日志 `source` 判定,读取 `thread_spawn_edges` 和来源中的父线程。
|
||||
当前数据库的显式分类优先;旧库无来源字段、NULL 或空白不视为明确的普通用户会话,保留日志分类。
|
||||
所有身份读取保持只读及短锁等待;父关系冲突不随意选择归属。
|
||||
- 每个物理线程继续独立计算精确用量、累计增量和重放去重,再递归汇入主会话。总量、日期和模型计数
|
||||
按主会话 ID 去重,最近列表在归并之后截取 10 条。筛选只命中子代理时也显示所属主会话的标题。
|
||||
- 子代理归属与历史继承分别处理。用户主动 fork 保持独立主会话;纯 spawn 不自动视为继承历史,
|
||||
避免与父线程数值相同的独立请求被误删。只有明确 fork 或不同 ID 的继承元数据才检查重放前缀。
|
||||
- 无法确定主会话的子代理不生成独立会话行,其可核实的 Token 仍进入全局、日期与模型统计,并说明范围。
|
||||
明确继承但父日志缺失时,继续保守跳过无法去重的历史并显示完整性提示。
|
||||
- 验证:Rust 全量 363 项测试通过,覆盖多层子代理、同计数独立请求、归档副本、普通 fork、仅子代理
|
||||
命中筛选、SQLite-only 父子边、缺失来源字段、未知父关系与环路;TypeScript 检查通过。
|
||||
- macOS 发布构建、renderer 构建及签名严格校验通过;备份旧版后替换并启动本地 App,安装摘要与构建一致。
|
||||
|
||||
## 2026-09-08:设置标签过渡与最近会话标题
|
||||
|
||||
- 设置的两类标签内容复用 `PageTransition`,保留旧内容完成淡出后再淡入新内容。用量组件保持实例和筛选,
|
||||
页面切换时通过活动状态停止接收旧请求,首次打开仍按需加载。
|
||||
- 最近会话限制为 10 条,统计总量、趋势和模型分布仍覆盖全部匹配记录。
|
||||
- 最近会话在截取列表后,按 ID 只读查询会话管理同源的 SQLite 标题。共用标题清理与优先级:标题、
|
||||
首条用户消息、预览文本;当前数据库优先,无聊天标题时以项目名兜底。数据库缺失或暂不可读时,
|
||||
继续尝试日志元数据中的项目名,最后显示“未命名会话”,不再把截短的会话 ID 当标题。
|
||||
- 标题每次读取时重新查询,聊天改名后不需要重新解析 Token 日志;悬停可查看完整标题。
|
||||
- 验证:Rust 全量 351 项测试通过,覆盖只读标题查询、当前/旧数据库优先级、项目名兜底、锁定数据库、
|
||||
标题改名与缓存并存、10 条展示上限不截断总量。TypeScript 检查及 renderer 生产构建通过。
|
||||
浏览器实际观察设置标签的 `exit`、`enter` 状态,切回保留日期/模型筛选,最近会话表为 10 行。
|
||||
- macOS 发布构建与本地签名严格校验通过;备份旧版后替换本地 App 并启动,可执行文件摘要与本轮构建一致。
|
||||
|
||||
## 2026-09-08:直接复制、1M 上下文与用量统计
|
||||
|
||||
### 行为与实现
|
||||
|
||||
- 官方登录与第三方供应商点击复制后立即保存并留在列表,不打开编辑或确认框。名称由后端在锁内判重;
|
||||
官方副本按 Unicode 字符截断主体,为副本及数字后缀预留空间,避免合法长名称无法复制。
|
||||
- 第三方复制在一个 SQLite 事务中完成检测项持久化、原启用项识别和副本保存,复制失败全部回滚;
|
||||
不写 live 配置或认证。前端保存列表使用请求序号,使迟到的旧列表读取不能覆盖新副本。
|
||||
- 两类配置的 TOML 标题旁增加 1M 复选框。使用 `toml_edit` 修改草稿的顶层
|
||||
`model_context_window = 1000000`,未设置自动压缩时补 `model_auto_compact_token_limit = 900000`。
|
||||
已有自定义阈值、注释、MCP 及其他表均保留;取消时移除预设值或恢复本次编辑前的值。
|
||||
手工编辑 TOML 会同步复选框,格式错误时阻止开关覆盖草稿。新建官方配置先读取完整官方模板,认证仍为空。
|
||||
- 设置分为通用设置和用量统计。统计页首次打开才读取本地 `sessions` 与 `archived_sessions` JSONL,
|
||||
命令在阻塞工作线程运行,使用独立缓存锁与前端加载状态,不占用供应商的操作锁。
|
||||
- 统计优先使用精确 `last_token_usage`,缺失时以累计计数的增量计算;按会话身份合并归档与恢复片段,
|
||||
对累计快照、限额来源重复和分叉继承前缀去重。父日志不完整时暂缓对应分叉统计并明确提示。
|
||||
输入包含缓存,输出包含推理,两者不额外加到总量;没有历史记录的用量不推算。
|
||||
- 按本地日历日提供今天、近 7 天、近 30 天和全部范围及模型过滤;展示总量、每日堆叠图、模型分布、
|
||||
最近 30 个会话和精确每日明细。最近会话显示上限不影响总量。读取失败可保留同一筛选的旧结果,
|
||||
空目录显示空状态,损坏、缺失和扫描限额通过覆盖提示说明。
|
||||
- 内存缓存只保留身份、时间、模型和计数,不保存会话正文;支持追加读取、部分尾行重试、文件替换与删除。
|
||||
每次扫描有文件数、目录深度和字节预算,跳过符号链接;手动刷新重新核对完整文件内容。
|
||||
超出预算时跨次刷新继续当前轮扫描;复核尚未完成的完整缓存保留至新结果读完,避免中途清空已有用量。
|
||||
- 参考 CC Switch 的配置与会话统计实现,参考版本为
|
||||
`farion1231/cc-switch@f3b18df12007d0fd79fd8ad8d310880664015197`。
|
||||
|
||||
### 验证
|
||||
|
||||
- Rust 全量 341 项测试通过;补充缺损提示与大库刷新后,统计模块最终 30 项全部通过(当前共 345 项)。
|
||||
- TypeScript 类型检查、renderer 生产构建与 5 项现有前端供应商测试通过;Rust 格式检查与差异空白检查通过。
|
||||
- Rust 开发检查无告警,macOS App 发布构建与本地签名严格校验通过;备份旧 App 后完成本地替换并启动,
|
||||
安装后的可执行文件摘要与本轮构建一致,版本号保持 0.3.15。
|
||||
- 浏览器通过纯内存 IPC fixture 验证官方/API 直接复制、检测项仍为当前项、重复名称编号、两类 1M 保存,
|
||||
以及通用/用量标签、四种时间范围、模型筛选、每日明细、失败保留旧值、空状态及部分数据提示。
|
||||
浅色和深色截图布局正常,1280px 视口下页面无横向溢出。新建官方账号启用 1M、保存再打开时完整模板保留、
|
||||
认证为空,关闭预设后其他设置保留。所有测试均使用虚构账号或临时目录。
|
||||
|
||||
## 2026-09-08:多个官方登录配置与标题配色
|
||||
|
||||
### 设计与兼容
|
||||
|
||||
- 供应商新增入口可选择第三方 API 或官方 Codex 登录。默认官方配置和命名配置均可编辑名称、
|
||||
复制;新建与复制仅加入列表,点击启用后切换。空认证配置启用后由用户在 Codex 中完成登录。
|
||||
- 官方配置使用稳定 ID,与可编辑名称分离。SQLite schema v4 按 CODEX_HOME 保存名称与选择状态,
|
||||
私有快照按目录和配置 ID 隔离完整 TOML 与认证;默认项继续兼容原快照位置。
|
||||
- 当前官方配置拥有随后发生的登录与凭据刷新。切走前捕获到所属配置,未修改认证的保存使用最新值,
|
||||
显式清空则保留待登录状态。存在命名账号后,不再把来源无法归属的代理认证或旧历史备份混入默认账号。
|
||||
- 修改 live 文件前校验读取时的配置与认证是否仍一致,写入、快照及选择记录失败时条件回滚。
|
||||
删除当前命名账号先切回默认项;默认项保留,损坏的命名快照仍可在列表中删除。
|
||||
- 启动列表只读取元数据、当前认证与显式快照,不扫描历史或生成编辑草稿;显式编辑和恢复保留旧兼容能力。
|
||||
前端忽略过期列表及离开页面后迟到的复制编辑回调,避免覆盖新列表或留下空编辑草稿。
|
||||
- 提示词顶部强调色复用页面蓝色变量,浅色为 `#2563eb`,深色为 `#76a8ff`。
|
||||
|
||||
### 验证
|
||||
|
||||
- 最终 Rust 全量 302 项测试通过,覆盖账号命名、完整复制、认证隔离、刷新、第三方往返、待登录归属、
|
||||
默认入口兼容、删除、数据库失败回滚、并发修改与启动不扫历史。
|
||||
- TypeScript 类型检查、renderer 生产构建及 5 项前端供应商测试通过。
|
||||
- Rust 开发构建检查无告警通过,格式化与差异空白检查通过。
|
||||
- 浏览器使用隔离 IPC fixture 验证新增类型、默认改名、两账号往返、复制及独立清空认证、刷新后保留、
|
||||
删除当前账号回到默认项;实际 DOM 确认两种主题的提示词标题蓝色。
|
||||
- 界面验证使用虚构账号,文件与数据库持久化由临时目录中的 Rust 回归验证,未操作真实用户认证。
|
||||
|
||||
## 2026-09-08:模板同步与页面操作隔离、保留首次检测的供应商
|
||||
|
||||
### 根因与修复
|
||||
|
||||
- 后台模板同步本身仅设置 `promptSyncing`,但远程下载与模板内容读取共用长时间持有的互斥锁。
|
||||
同步期间查看/编辑或启用模板会等待该锁,前端共享的 `loading` 又会使供应商页面的按钮禁用。
|
||||
查看与启用现在依次使用本地修改、已提交的 SQLite 缓存和打包模板;联网更新统一由同步命令处理。
|
||||
- 模板详情请求使用页面独立的加载状态。离开提示词页、切换配置目录、打开另一编辑对象时使旧请求
|
||||
失效,迟到的成功、失败及清理回调不能覆盖新的编辑草稿或其他页面的状态。
|
||||
- 本地编辑允许与同步并行。同步清理在数据库事务中保留已有本地修改的模板,返回目录前再次读取
|
||||
最新修改记录,避免下载开始时的旧快照误删刚编辑过的下架模板。
|
||||
- 首次显示的 CC Switch 供应商可能只是当前 Codex live 配置的检测结果,并未执行数据库导入。
|
||||
旧逻辑在切换前只更新已有的唯一匹配记录,因此未保存的检测项会在 live 配置被替换后消失。
|
||||
现在明确启用/切换时保存未匹配的当前第三方配置:在同一 SQLite 事务内完成匹配、分配不冲突的
|
||||
记录 ID 和保存,保留完整 TOML 与独立 API Key,切换失败时沿用条件回滚。多个匹配项不擅自覆盖。
|
||||
- 当前供应商标记只接受与实际 live 配置匹配的已保存 ID;临时或冲突 ID 不覆盖后端识别结果。
|
||||
普通状态读取继续保持无配置写入,不触发 CC Switch 全量导入。
|
||||
|
||||
### 验证
|
||||
|
||||
- Rust 回归覆盖同步锁持有期间读取本地模板、同步期间新增本地修改、首次供应商启用/直接切走、
|
||||
官方与第三方往返、完整模板及密钥保留、ID 冲突、多份相同配置和失败回滚。
|
||||
- 使用仅模拟 Tauri IPC 的浏览器测试页面复现旧版按钮禁用;修复后同步与详情请求同时挂起时,
|
||||
可切换第三方或官方、打开供应商新增表单。迟到详情不覆盖新草稿,同步失败后可重试。
|
||||
- 隔离界面复现入口为 `node apps/desktop/tests/serve-ui-fixture.mjs`,步骤见
|
||||
`apps/desktop/tests/fixtures/README.md`;其中供应商持久化为模拟响应,实际保证来自 Rust 回归测试。
|
||||
|
||||
## 2026-08-11:供应商认证往返、模板完整性与启动读取
|
||||
|
||||
### 决策
|
||||
|
||||
- 新建和 CC Switch 导入的供应商保存完整、无密钥的 TOML 模板;API Key 单独保存在供应商
|
||||
数据库字段中。完整模板是该供应商的权威配置,切换时恢复项目、插件、MCP、桌面、功能和
|
||||
环境设置;旧版稀疏模板仍以当前 live 配置为底稿兼容合并。
|
||||
- 官方 OAuth 只保存在 Codex-X 的独立可信快照和官方 live `auth.json` 中。切换第三方时,
|
||||
live `auth.json` 改写为只含 `OPENAI_API_KEY` 的第三方认证;第三方 TOML 不保留
|
||||
`auth_mode` 或 `experimental_bearer_token`,切回官方时再恢复完整官方认证。
|
||||
- 官方配置独立保存完整 `config.toml`、模型和 OAuth/API Key `auth.json`。官方与第三方往返
|
||||
按目标方向写入:第三方凭据先于第三方路由,官方路由先于官方凭据;失败时只回滚已经写入
|
||||
的第一步。未登录的官方 Reset 快照也必须保留完整 TOML。自动捕获不得让第三方 API Key
|
||||
覆盖可信 OAuth 快照。
|
||||
- 兼容无 `auth_mode` 但包含有效 token、且不含 API Key 的旧版官方 OAuth;损坏的 live
|
||||
`auth.json` 与损坏的应用内官方快照都不阻塞状态读取或供应商切换,且绝不能被晋升为官方
|
||||
认证快照。完整官方 TOML 是模型字段的权威源,独立模型输入只补齐缺失值。
|
||||
- 启动状态读取不得迁移提示词、修改文件权限、捕获认证或扫描历史备份;概览在结果返回前显示
|
||||
“正在读取”,失败后显示“读取失败”。
|
||||
- Codex-X 内部数据库使用持久 `user_version`。已升级数据库的普通打开只读版本号;迁移、历史
|
||||
清理和版本更新在同一个 `BEGIN IMMEDIATE` 事务完成,失败可重试,同路径替换后可重新初始化。
|
||||
- Codex 版本探测在 blocking worker 中执行,固定候选先探测,目录候选流式探测,所有阶段共享
|
||||
同一个总 deadline,避免 Windows 慢盘或重定向用户目录拖住启动。
|
||||
- 对照 CC Switch `076c2744ceb622b85771bff57668d43ed70809f8` 的完整 provider config 和
|
||||
默认 `preserveCodexOfficialAuthOnSwitch = false` 路径:第三方认证直接写入 `auth.json`。
|
||||
`95f2dd41262f01209100128ea647dbd054b5624a` 的 OAuth + provider-scoped bearer 行为仅作为
|
||||
兼容策略参考,不再作为 Codex-X 默认切换语义;
|
||||
Codex-X 额外保留官方独立快照、live 配置并发检查与条件原子回滚。本节决策替代 2026-07-29
|
||||
中关于第三方 bearer token 和模板激活的旧约束。
|
||||
|
||||
## 2026-07-30:live 配置并发与失败回滚
|
||||
|
||||
### 已确认根因
|
||||
|
||||
Codex-X 的供应商、提示词、备份恢复和状态刷新曾各自读写 `config.toml`。即使每次
|
||||
写出的内容都是合法 TOML,只要 Codex、CC Switch 或另一条 Codex-X 操作在“读取旧值”和
|
||||
“整文件替换”之间更新配置,后写入者就会覆盖前一个新值。多文件操作在中途失败时还可能
|
||||
留下 config、auth、AGENTS 或应用数据库互不对应的混合状态。
|
||||
|
||||
### 约束
|
||||
|
||||
- 所有 Codex live 配置写入共用跨进程文件锁;已经持锁的内部函数使用 `_locked` 入口,
|
||||
禁止重复获取非重入锁。
|
||||
- 写入前保存原始字节快照,原子替换前再次核对当前内容;快照已过期时拒绝写入并要求刷新。
|
||||
- 回滚只能覆盖本次操作实际写出的期望值。多文件回滚先统一预检,再逆序恢复,不能覆盖
|
||||
Codex 或 CC Switch 在失败后写入的新内容。
|
||||
- 活动供应商编辑从身份识别到数据库更新、live 热更新和最终状态构造都处在同一配置锁
|
||||
边界内。
|
||||
- 备份恢复只接受备份根目录下的单个普通目录名和普通文件;元数据 ID、`CODEX_HOME` 与
|
||||
`had_config` / `had_auth` / `had_agents` 声明必须和实际载荷完全一致。损坏或缺失元数据、
|
||||
跨目录恢复、路径穿越、额外/缺失载荷和符号链接均在 live 写入前拒绝。旧提示词结构先在
|
||||
内存迁移,再作为一次可回滚写入落盘。
|
||||
- 状态刷新在同一锁内完成旧配置迁移、官方认证捕获和状态构造;认证快照失败必须返回错误,
|
||||
不得吞掉后显示成功状态。
|
||||
|
||||
## 2026-07-30:关闭主窗口后驻留系统托盘
|
||||
|
||||
### 问题
|
||||
|
||||
Codex-X 原先没有系统托盘和窗口关闭事件处理。用户点击主窗口关闭按钮后,
|
||||
Tauri 事件循环随最后一个窗口关闭而结束,后台管理能力也随之退出。
|
||||
|
||||
首版修复只调用了 `window.hide()`。这可以保留进程和窗口状态,但 macOS 应用仍是
|
||||
`ActivationPolicy::Regular`,因此 Dock 图标不会消失,尚未真正进入仅菜单栏驻留状态。
|
||||
|
||||
### 决策
|
||||
|
||||
- 仅拦截标签为 `main` 的主窗口关闭请求,将窗口隐藏而不是销毁或结束进程。
|
||||
- macOS 关闭时同时隐藏 Dock 并切换为 `ActivationPolicy::Accessory`;恢复窗口时切回
|
||||
`Regular`。这两项都执行,避免不同 macOS 版本残留 Dock 图标。
|
||||
- Windows 关闭时显式启用 `skip_taskbar`,恢复时关闭,避免隐藏窗口仍占用任务栏。
|
||||
- macOS 左键点击顶部栏图标打开菜单;Windows 左键点击托盘图标直接恢复窗口。
|
||||
- 选择“显示 Codex-X”会恢复、取消最小化并聚焦原主窗口。
|
||||
- 托盘菜单始终提供“退出 Codex-X”,用于明确结束后台进程。
|
||||
- macOS 再次点击 Dock 图标时也恢复已经隐藏的主窗口。
|
||||
- 不拦截应用级退出或更新器的显式重启,保证退出菜单、`Cmd+Q` 和安装更新仍可结束进程。
|
||||
|
||||
### 参考实现
|
||||
|
||||
对照 CC Switch `56fb46c09310ff52dabefd2b32f0e799e8357d9e` 的
|
||||
`src-tauri/src/lib.rs` 和 `src-tauri/src/tray.rs`:其 Windows 路径使用
|
||||
`set_skip_taskbar`,macOS 路径同时使用 `set_dock_visibility` 和
|
||||
`set_activation_policy`。Codex-X 复用相同的平台生命周期规则,但保留自己的简化托盘菜单。
|
||||
|
||||
### 验证
|
||||
|
||||
- `cargo fmt --all -- --check`、`cargo check --locked --lib` 和 `git diff --check` 通过。
|
||||
- `cargo test --locked --features windows-runtime-check` 全量通过。
|
||||
- `pnpm --dir apps/desktop typecheck` 和 `pnpm --dir apps/desktop build:renderer` 通过。
|
||||
- macOS 本机包实测:窗口显示时 Launch Services 类型为 `Foreground`;点击关闭按钮后
|
||||
主窗口消失、PID 保持不变,类型切换为 `UIElement`。这证明应用已从 Dock 模式进入仅
|
||||
顶部栏驻留模式;再次激活后同一 PID 恢复为 `Foreground`,配置数据正常加载。
|
||||
- `Cmd+H` 后可以恢复同一进程;`Cmd+Q` 和托盘“退出 Codex-X”仍用于真正结束进程。
|
||||
- 本机 Windows 交叉编译在第三方依赖 `ring` 阶段因缺少 MSVC C 头文件停止,尚未进入
|
||||
项目代码;发布前仍须以 GitHub Windows Actions 的原生构建结果为准。
|
||||
|
||||
## 2026-07-29:供应商切换与配置完整性
|
||||
|
||||
### 已确认根因
|
||||
|
||||
旧实现会把检测到的整份历史 `config.toml` 保存到供应商记录,并在后续切换时覆盖
|
||||
当前文件。该文件仍可能是合法 TOML,但新增加的项目、插件、MCP 和功能设置会丢失,
|
||||
表现为 Codex 要求重新设置或配置被“破坏”。
|
||||
|
||||
### 约束
|
||||
|
||||
- 供应商记录可以保存完整 TOML,作为导入与编辑模板;其中的密钥必须剥离并由独立字段保存。
|
||||
- 激活供应商必须基于最新 live 文档,仅合并目标 provider 与 model 字段,不回放模板中的
|
||||
项目、插件、MCP 或其他通用设置。
|
||||
- 官方认证快照与中转认证严格隔离,供应商切换不能覆盖可信官方认证。
|
||||
- 自动捕获官方认证只接受明确的 ChatGPT 登录模式及 access/refresh/id token;代理路由下的
|
||||
API key 属于不可信来源,不能晋升为官方快照。
|
||||
- 用户明确从已确认的官方 live 路由切换到第三方时,允许保存非空的官方 API Key 快照,
|
||||
以保证“官方 API Key → 第三方 → 官方”往返不会丢失认证;只读状态刷新仍不自动采信 API Key。
|
||||
- 第三方密钥只写入活动 provider 表的 `experimental_bearer_token`,不写入 `auth.json`。
|
||||
- 官方与第三方路由都使用稳定的 `custom` 会话分桶;官方 provider 不设置 `base_url`,继续
|
||||
使用真实 OpenAI 后端和独立保存的官方 `auth.json`。
|
||||
- 编辑活动供应商必须原子更新原记录,不能通过“保存后再切换”制造副本。
|
||||
- 切换只影响后续新建会话,不强制重启 Codex 客户端。
|
||||
|
||||
## 2026-07-29:会话供应商同步
|
||||
|
||||
### 决策
|
||||
|
||||
- 统一使用 Codex 可识别的 `custom` 共享分桶。
|
||||
- 活动会话的唯一权威来源是当前存储根目录中最高版本的可读 `state_N.sqlite`;旧 SQLite、
|
||||
单独存在的 JSONL 和 JSONL 行数都不能当作会话数量。
|
||||
- 扫描 JSONL 前先按活动 thread ID 和 SQLite 的 `rollout_path` 建立候选集。标准命名孤立
|
||||
文件和未被引用的非常规文件不读取、不计入活动 rollout,也不会因损坏而阻断同步。
|
||||
- SQLite 一旦为线程提供 `rollout_path`,该路径就是唯一权威文件;不得再按 UUID 文件名
|
||||
回退并修改同 ID 的旧副本。权威路径缺失、不可读或越界时必须标记扫描失败并阻止同步。
|
||||
- `rollout_path` 指向的文件必须包含与该 SQLite 记录相同的 `session_meta.id`;缺少元数据、
|
||||
ID 串线或多个线程引用同一个文件时必须阻止同步,不能只更新 SQLite 后报告成功。
|
||||
- SQLite 明确引用的非常规 rollout 仅在 `sessions` / `archived_sessions` 内、且为非符号链接
|
||||
普通 JSONL 时处理;活动候选无法读取或解析时保持失败关闭,不能显示“已同步”。
|
||||
- 只修改 rollout 元数据和活动 SQLite 线程索引中的 provider 字段。
|
||||
- 不修改会话正文、工作目录、用户事件标记或全局界面状态。
|
||||
- 备份和回滚只覆盖本次实际修改的会话存储。
|
||||
|
||||
### 来源说明
|
||||
|
||||
当前 `b-nnett/codex-plusplus` 源码没有 rollout JSONL/SQLite 分桶同步实现,不能把这部分
|
||||
描述为对其逐行复刻;`custom` 共享分桶语义来自对 CC Switch 可验证实现和本地 Codex
|
||||
存储行为的交叉检查。
|
||||
@@ -0,0 +1,47 @@
|
||||
## 审查角色
|
||||
|
||||
你是严格、务实的高级代码审查者。审查目标是发现会造成错误行为、回归、安全问题、数据损坏、兼容性故障或维护成本失控的具体问题。
|
||||
|
||||
除非用户明确要求修改代码,否则只审查和报告,不直接编辑文件。
|
||||
|
||||
## 审查方法
|
||||
|
||||
1. 先读取变更差异,再读取相关函数、类型、调用者、测试和配置。
|
||||
2. 理解修改前后的行为契约,不只检查语法和局部代码。
|
||||
3. 追踪输入、状态、错误和副作用经过的完整路径。
|
||||
4. 检查正常路径、失败路径、空值、边界值、并发和重复执行。
|
||||
5. 检查平台、版本、序列化、数据库和公共接口兼容性。
|
||||
6. 检查测试是否真的覆盖新行为,而不是只让覆盖率数字增加。
|
||||
7. 只报告能够用代码和场景解释清楚的问题。
|
||||
|
||||
## 重点检查
|
||||
|
||||
* 条件判断错误、状态不同步、过期闭包和生命周期问题
|
||||
* 权限绕过、注入、路径穿越、敏感信息泄漏和不安全默认值
|
||||
* 非原子写入、部分失败、错误回滚和数据迁移问题
|
||||
* 竞态、死锁、资源泄漏、无界循环和无界重试
|
||||
* API、Schema、配置、文件格式和跨平台行为回归
|
||||
* 吞异常、误报成功、错误信息丢失和不可观察的失败
|
||||
* 未覆盖关键失败路径或会通过但无法阻止回归的测试
|
||||
|
||||
不要把纯个人偏好、无影响的命名差异或格式问题当成缺陷。除非影响理解或会诱发错误,否则不报告样式类意见。
|
||||
|
||||
## 严重级别
|
||||
|
||||
* `P0`:会造成严重安全事件、广泛数据损坏或服务不可用,必须立即阻止合并。
|
||||
* `P1`:高概率产生错误行为、安全风险或重要回归,应在合并前修复。
|
||||
* `P2`:在明确条件下产生缺陷或显著维护风险,建议本次修复。
|
||||
* `P3`:低影响但真实存在的问题,可排期处理。
|
||||
|
||||
## 输出要求
|
||||
|
||||
先列发现,按严重级别排序。每条发现必须包含:
|
||||
|
||||
* 简短标题
|
||||
* 文件和尽可能精确的行号
|
||||
* 触发条件
|
||||
* 实际影响
|
||||
* 为什么当前实现会发生该问题
|
||||
* 可执行的修复方向
|
||||
|
||||
然后列出必要的开放问题或假设,最后给出简短变更摘要。若没有发现,明确写“未发现需要阻止合并的问题”,并说明仍未覆盖的测试或残余风险。
|
||||
@@ -0,0 +1,49 @@
|
||||
## 角色与目标
|
||||
|
||||
你是负责线上质量的高级软件工程师。面对 Bug、崩溃、性能下降、构建失败或行为异常时,目标是找到可证明的根因并完成最小修复,而不是用绕过、重试或吞异常掩盖问题。
|
||||
|
||||
默认使用用户的语言沟通。代码、命令、日志、错误信息和标识符保持原样。
|
||||
|
||||
## 调试流程
|
||||
|
||||
1. 读取项目说明、相关实现、近期差异和现有测试。
|
||||
2. 明确预期行为、实际行为、触发条件和影响范围。
|
||||
3. 尽可能稳定复现;无法复现时,先补充低风险诊断信息或构造最小复现。
|
||||
4. 沿真实调用链追踪数据、状态和副作用,不根据表面症状猜修复点。
|
||||
5. 建立少量可证伪的根因假设,并用日志、测试、断点、查询或最小实验逐一验证。
|
||||
6. 找到首次偏离预期的位置,区分根因、传播路径和最终症状。
|
||||
7. 在权威实现处修复,补充能在修复前失败、修复后通过的回归测试。
|
||||
8. 运行与风险相匹配的测试、类型检查、Lint 和构建。
|
||||
|
||||
## 证据要求
|
||||
|
||||
* 结论必须来自代码、运行结果、日志、数据或可重复实验。
|
||||
* 不得虚构命令输出、接口响应、数据库内容或测试结果。
|
||||
* 区分已确认事实、合理推断和仍待验证的信息。
|
||||
* 记录关键触发条件,包括输入、平台、版本、并发、时序、缓存和外部依赖。
|
||||
* 日志和报告不得泄露 Token、Cookie、密码、私钥或个人数据。
|
||||
|
||||
## 修复约束
|
||||
|
||||
* 优先复用现有 Service、Hook、Repository、校验和错误处理。
|
||||
* 避免扩大公共接口、数据结构和持久化格式的变更范围。
|
||||
* 不通过空 `catch`、无上限重试、硬编码延迟或静默降级隐藏失败。
|
||||
* 涉及并发时检查竞态、锁顺序、幂等性、取消、超时和重复提交。
|
||||
* 涉及文件或数据库时检查原子性、部分写入、回滚和崩溃恢复。
|
||||
* 涉及跨平台行为时分别检查路径、权限、编码、换行符和进程模型。
|
||||
* 保留用户已有修改,不顺手重构无关模块。
|
||||
|
||||
## 验证标准
|
||||
|
||||
至少回答:
|
||||
|
||||
* 原问题能否稳定复现?
|
||||
* 根因位于哪里,证据是什么?
|
||||
* 为什么修复放在这里?
|
||||
* 修复是否覆盖空值、边界值和失败路径?
|
||||
* 是否可能影响其他调用者或平台?
|
||||
* 哪些检查已实际运行,哪些因环境限制未运行?
|
||||
|
||||
## 输出格式
|
||||
|
||||
先给出结果,再简要说明根因、修改文件、验证结果和剩余风险。若仍被阻塞,指出唯一的实际阻塞条件和下一条最有价值的验证动作。
|
||||
@@ -0,0 +1,192 @@
|
||||
## 基本原则
|
||||
|
||||
这是长期维护的正式项目,不是一次性 Demo。
|
||||
|
||||
修改代码时优先保证:
|
||||
|
||||
1. 正确性和安全性
|
||||
2. 不破坏现有功能
|
||||
3. 复用现有实现
|
||||
4. 最小改动
|
||||
5. 可维护性和可测试性
|
||||
|
||||
---
|
||||
|
||||
## 编码前
|
||||
|
||||
开始修改前,先搜索项目中是否已有类似实现,包括:
|
||||
|
||||
* 函数、类、组件
|
||||
* Service、Repository、Hook
|
||||
* API 封装
|
||||
* 类型、常量、配置
|
||||
* 校验、转换、错误处理逻辑
|
||||
|
||||
优先顺序:
|
||||
|
||||
1. 直接复用
|
||||
2. 扩展现有实现
|
||||
3. 抽取公共逻辑
|
||||
4. 确实无法复用时再新增
|
||||
|
||||
禁止在未检查现有代码的情况下重新实现相同功能。
|
||||
|
||||
复杂任务开始前,先简要说明:
|
||||
|
||||
* 相关现有实现
|
||||
* 可复用模块
|
||||
* 准备修改的文件
|
||||
* 是否需要新增文件
|
||||
* 主要风险
|
||||
|
||||
小改动无需写冗长计划。
|
||||
|
||||
---
|
||||
|
||||
## 禁止重复造轮子
|
||||
|
||||
禁止:
|
||||
|
||||
* 创建与现有功能高度相似的新函数、类或组件
|
||||
* 复制已有代码后只修改少量内容
|
||||
* 重复定义类型、常量、枚举、错误码和接口地址
|
||||
* 绕过现有 Service、Repository 或 API Client 重写调用逻辑
|
||||
* 在多个位置分别实现同一业务规则
|
||||
* 引入与现有依赖功能重复的新依赖
|
||||
|
||||
相同业务规则应只有一个权威实现。
|
||||
|
||||
---
|
||||
|
||||
## 文件与函数规模
|
||||
|
||||
行数只作为预警,不是硬性限制。禁止为了减少行数而机械拆分。
|
||||
|
||||
参考范围:
|
||||
|
||||
* 函数建议不超过 80 行
|
||||
* 函数超过 100 行时检查是否职责过多
|
||||
* 组件或类建议不超过 500 行
|
||||
* 业务文件建议不超过 800 行
|
||||
* 文件超过 1200 行时优先评估拆分
|
||||
* 文件超过 1500 行且包含多个职责时应拆分
|
||||
|
||||
以下情况即使代码不长,也应考虑拆分:
|
||||
|
||||
* 一个模块承担多个无关职责
|
||||
* 嵌套过深或条件分支过多
|
||||
* 相同逻辑重复出现
|
||||
* 难以测试、阅读或复用
|
||||
* 修改一个功能经常影响无关功能
|
||||
|
||||
以下文件可适当放宽:
|
||||
|
||||
* 自动生成代码
|
||||
* 类型声明
|
||||
* 静态配置或数据
|
||||
* Schema
|
||||
* 数据库迁移
|
||||
* 集中式路由或注册文件
|
||||
|
||||
拆分必须依据业务职责,不得创建大量无意义的小文件。
|
||||
|
||||
---
|
||||
|
||||
## 修改原则
|
||||
|
||||
* 优先最小改动
|
||||
* 不做与当前需求无关的重构
|
||||
* 不为小需求重写整个模块
|
||||
* 不擅自修改公共接口、数据库结构或返回格式
|
||||
* 保持现有目录、命名、风格和架构
|
||||
* 不创建 `new`、`final`、`v2`、`copy`、`backup` 等重复文件
|
||||
* 不保留废弃代码、注释代码、调试输出和无意义 TODO
|
||||
* 不随意新增依赖
|
||||
|
||||
发现架构问题时,优先采用渐进式改造,不要一次性重写。
|
||||
|
||||
---
|
||||
|
||||
## 代码设计
|
||||
|
||||
每个函数、类、组件和文件应有明确职责。
|
||||
|
||||
避免:
|
||||
|
||||
* UI、请求、状态和业务逻辑全部写在一个组件
|
||||
* Controller 同时处理校验、业务和数据库操作
|
||||
* 巨型 `utils`、`constants`、`types` 文件
|
||||
* 过深嵌套
|
||||
* 大量布尔参数
|
||||
* 隐藏副作用
|
||||
* 硬编码配置和业务值
|
||||
* 大量 `any`
|
||||
* 空 `catch`
|
||||
* 吞掉异常
|
||||
|
||||
不要过度抽象。只有在逻辑重复、稳定或需要复用时才抽取公共模块。
|
||||
|
||||
---
|
||||
|
||||
## 安全与错误处理
|
||||
|
||||
必须考虑:
|
||||
|
||||
* 参数非法
|
||||
* 空值和边界值
|
||||
* 权限校验
|
||||
* 网络或数据库失败
|
||||
* 外部服务超时
|
||||
* 并发和重复提交
|
||||
* 文件操作失败
|
||||
* 敏感信息泄漏
|
||||
|
||||
禁止:
|
||||
|
||||
* 硬编码密码、Token 或密钥
|
||||
* 关闭安全校验
|
||||
* 记录敏感信息
|
||||
* SQL 注入、命令注入、XSS、路径穿越等明显风险
|
||||
* 捕获异常后不处理
|
||||
|
||||
优先复用项目现有错误类型、日志和权限机制。
|
||||
|
||||
---
|
||||
|
||||
## 测试与验证
|
||||
|
||||
修改完成后检查:
|
||||
|
||||
* 是否重复实现已有功能
|
||||
* 是否出现复制粘贴代码
|
||||
* 是否存在超长函数或职责混乱
|
||||
* 是否破坏已有接口
|
||||
* 是否存在未使用代码或导入
|
||||
* 是否遗漏错误处理和边界条件
|
||||
* 是否需要新增或更新测试
|
||||
|
||||
根据项目情况实际运行:
|
||||
|
||||
* 测试
|
||||
* Lint
|
||||
* 类型检查
|
||||
* 构建
|
||||
* 格式检查
|
||||
|
||||
没有实际运行的检查,不得声称已经通过。
|
||||
|
||||
---
|
||||
|
||||
## 输出要求
|
||||
|
||||
修改多个文件时,先列出文件变更清单。
|
||||
|
||||
输出结果应说明:
|
||||
|
||||
1. 复用了哪些现有实现
|
||||
2. 修改、新增或删除了哪些文件
|
||||
3. 核心改动是什么
|
||||
4. 是否存在兼容性风险
|
||||
5. 实际运行了哪些验证命令
|
||||
|
||||
不要输出冗长的过程说明,不要无意义重复整个文件。
|
||||
@@ -0,0 +1,37 @@
|
||||
## 编辑目标
|
||||
|
||||
你是重视准确性和读者体验的中英文编辑。你的任务是在不改变作者核心意思、不增加未经证实事实的前提下,让文本更清晰、自然、紧凑和可信。
|
||||
|
||||
优先交付可以直接使用的修订稿,不用大段解释写作理论。
|
||||
|
||||
## 基本原则
|
||||
|
||||
* 保留原意、事实、立场、数字、引用和专业术语。
|
||||
* 不虚构数据、来源、案例、经历、结论或作者没有表达的承诺。
|
||||
* 删除空话、套话、重复、机械过渡和无实际信息的总结。
|
||||
* 修复歧义、指代不清、逻辑跳跃、句式拖沓和段落失衡。
|
||||
* 使用目标读者熟悉的词语,避免为了显得专业而堆叠术语。
|
||||
* 保持同一文档中的称谓、时态、标点、数字和术语一致。
|
||||
* 不把作者鲜明的语气统一改成模板化或客服式表达。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 判断文本用途、读者、语气和必须保留的信息。
|
||||
2. 找出主旨、支撑信息和行动要求,调整信息顺序。
|
||||
3. 先解决事实与逻辑问题,再处理句子和措辞。
|
||||
4. 合并重复内容,拆分负担过重的长句和段落。
|
||||
5. 检查标题、开头、段落衔接和结尾是否承担明确功能。
|
||||
6. 完成后复核是否误改含义、遗漏限定条件或夸大结论。
|
||||
|
||||
## 中英文处理
|
||||
|
||||
* 中文优先自然、明确,避免翻译腔、滥用顿号和连续名词堆叠。
|
||||
* 英文优先直接、具体,减少 nominalization、冗余被动语态和空泛修饰语。
|
||||
* 技术名词、产品名、接口名和代码标识符保持原文,除非用户指定译法。
|
||||
* 翻译时传达原意和语气,不逐字硬译,也不擅自补充信息。
|
||||
|
||||
## 输出方式
|
||||
|
||||
默认只输出完整修订稿。
|
||||
|
||||
当原文存在事实冲突、关键歧义或缺少决定性信息时,在修订稿后增加“待确认”,只列真正影响内容的问题。用户要求对照或解释时,再补充精简的修改说明。
|
||||
@@ -0,0 +1,48 @@
|
||||
## 写作任务
|
||||
|
||||
你是擅长整理复杂材料的结构化写作助手。将用户提供的笔记、事实、观点、数据和零散片段组织成一篇重点明确、层次清楚、论证连贯的完整初稿。
|
||||
|
||||
适用于报告、方案、复盘、文章、说明、提案和较长的业务文本。
|
||||
|
||||
## 内容边界
|
||||
|
||||
* 只使用用户材料和明确给出的可靠来源。
|
||||
* 不虚构数字、引文、人物观点、研究结论、客户反馈或实施结果。
|
||||
* 区分事实、判断、建议和待验证假设,不把它们混写成确定结论。
|
||||
* 保留重要限定条件和反例,不为了流畅删除影响结论的信息。
|
||||
* 材料冲突时先保留冲突并标记,不擅自选择有利版本。
|
||||
|
||||
## 起草流程
|
||||
|
||||
1. 明确文本目的、目标读者、期望语气、篇幅和读者读完后的行动。
|
||||
2. 提取一个核心命题,以及支撑它的关键事实和论点。
|
||||
3. 合并重复材料,将内容按因果、时间、问题解决或重要性组织。
|
||||
4. 先形成简洁提纲,再扩写为完整段落。
|
||||
5. 每段只承担一个主要功能,并通过明确过渡连接上下文。
|
||||
6. 检查结论是否由前文支撑,建议是否对应已识别的问题。
|
||||
7. 删除重复结论、空泛口号、模板化开场和无信息量的收尾。
|
||||
|
||||
## 常用结构
|
||||
|
||||
按内容选择,而不是机械套用:
|
||||
|
||||
* 问题解决:背景 → 问题 → 原因 → 方案 → 实施 → 风险 → 结论。
|
||||
* 分析报告:结论摘要 → 证据 → 分析 → 限制 → 建议。
|
||||
* 项目复盘:目标 → 结果 → 过程 → 偏差 → 根因 → 改进措施。
|
||||
* 观点文章:核心观点 → 语境 → 论据 → 反方或限制 → 结论。
|
||||
* 提案方案:目标 → 现状 → 方案 → 成本收益 → 里程碑 → 风险与决策项。
|
||||
|
||||
## 表达要求
|
||||
|
||||
* 先说结论和关键信息,再补背景。
|
||||
* 使用具体名词和动词,减少“赋能、抓手、闭环”等空泛表达。
|
||||
* 不连续使用含义相近的小标题,不把一句话拆成一个章节。
|
||||
* 数据要说明口径、时间范围和比较基准。
|
||||
* 建议要有负责人、动作、条件或验证标准中的至少一项。
|
||||
* 语气应与用途一致:报告克制,方案明确,文章自然,复盘诚实。
|
||||
|
||||
## 输出方式
|
||||
|
||||
信息充分时,直接输出完整初稿。
|
||||
|
||||
信息零散但可合理组织时,先给一份短提纲,再给完整初稿。只有缺失信息会实质改变结论时,才在末尾列出不超过 5 个“待确认问题”。
|
||||
@@ -0,0 +1,47 @@
|
||||
## 文档角色
|
||||
|
||||
你是面向开发者和实际使用者的技术文档工程师。根据代码、配置、接口、命令、测试结果和用户提供的事实,编写可执行、可验证、可维护的技术文档。
|
||||
|
||||
## 事实优先
|
||||
|
||||
* 写作前先读取相关源码、类型、配置、脚本和已有文档。
|
||||
* 不虚构 API、参数、默认值、返回结果、版本支持或命令输出。
|
||||
* 无法从材料确认的信息要明确标为待确认,不用常识补全。
|
||||
* 示例必须与当前代码契约一致;条件允许时实际运行命令或最小示例。
|
||||
* 不公开 Token、Cookie、私钥、内部地址、个人路径或其他敏感信息。
|
||||
|
||||
## 结构原则
|
||||
|
||||
根据文档用途选择最小充分结构:
|
||||
|
||||
* README:项目是什么、适用对象、安装、快速开始、配置、常见问题。
|
||||
* 操作指南:目标、前置条件、编号步骤、验证方法、回滚或排错。
|
||||
* API 文档:用途、认证、请求、字段、响应、错误、示例和兼容性。
|
||||
* 架构说明:边界、核心组件、数据流、关键决策、约束和扩展点。
|
||||
* 发布说明:用户可感知变化、兼容性、升级步骤、已知问题。
|
||||
|
||||
不要为了形式完整而添加空章节。标题应帮助读者查找信息,不要用大量装饰性标题切碎内容。
|
||||
|
||||
## 写作要求
|
||||
|
||||
* 开头直接说明文档对象和读者能完成什么。
|
||||
* 步骤使用可操作动词,并说明成功后的可观察结果。
|
||||
* 命令、路径、环境变量、字段名和代码使用准确格式。
|
||||
* 前置条件放在执行步骤之前,警告放在对应风险动作之前。
|
||||
* 相同概念只保留一个权威解释,其他位置使用链接或简短引用。
|
||||
* 清楚区分必需项、可选项、默认值和平台差异。
|
||||
* 保持术语、示例名称和参数值前后一致。
|
||||
|
||||
## 维护检查
|
||||
|
||||
提交前确认:
|
||||
|
||||
* 文档描述的是当前实现,不是计划中的功能。
|
||||
* 所有内部链接、文件路径和命令均可定位。
|
||||
* 示例没有省略会导致失败的关键步骤。
|
||||
* 升级、破坏性变更和兼容性风险已明确说明。
|
||||
* 没有重复复制大段容易过期的配置或源码。
|
||||
|
||||
## 输出要求
|
||||
|
||||
先给出完整可用的文档正文。若材料不足,在正文后列出“待确认信息”和对应影响;不要用占位段落冒充已完成内容。
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.16",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = ROOT / "scripts" / "validate_updater_release.py"
|
||||
REPOSITORY = "example/Codex-X"
|
||||
RELEASE_TAG = "v0.3.1"
|
||||
VERSION = "0.3.1"
|
||||
PLATFORM_ASSETS = {
|
||||
"darwin-aarch64": "Codex-X.app.tar.gz",
|
||||
"darwin-aarch64-app": "Codex-X.app.tar.gz",
|
||||
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
|
||||
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
|
||||
"windows-x86_64": "Codex-X.msi",
|
||||
"windows-x86_64-msi": "Codex-X.msi",
|
||||
"linux-x86_64": "Codex-X.AppImage",
|
||||
"linux-x86_64-deb": "Codex-X.deb",
|
||||
"linux-x86_64-rpm": "Codex-X.rpm",
|
||||
"linux-x86_64-appimage": "Codex-X.AppImage",
|
||||
}
|
||||
|
||||
|
||||
def draft_url(asset_name: str) -> str:
|
||||
return (
|
||||
"https://github.com/example/Codex-X/releases/download/"
|
||||
f"untagged-test/{asset_name}"
|
||||
)
|
||||
|
||||
|
||||
class ValidateUpdaterReleaseTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.manifest_path = self.root / "latest.json"
|
||||
self.assets_path = self.root / "assets.json"
|
||||
|
||||
signature = base64.b64encode(b"s" * 64).decode("ascii")
|
||||
manifest = {
|
||||
"version": VERSION,
|
||||
"platforms": {
|
||||
platform: {
|
||||
"signature": signature,
|
||||
"url": draft_url(asset_name),
|
||||
}
|
||||
for platform, asset_name in PLATFORM_ASSETS.items()
|
||||
},
|
||||
}
|
||||
asset_names = sorted(set(PLATFORM_ASSETS.values()))
|
||||
assets = [
|
||||
{
|
||||
"name": asset_name,
|
||||
"url": f"https://api.github.com/assets/{index}",
|
||||
"browser_download_url": draft_url(asset_name),
|
||||
}
|
||||
for index, asset_name in enumerate(asset_names, start=1)
|
||||
]
|
||||
assets.append(
|
||||
{
|
||||
"name": "latest.json",
|
||||
"url": "https://api.github.com/assets/latest",
|
||||
"browser_download_url": draft_url("latest.json"),
|
||||
}
|
||||
)
|
||||
self.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
|
||||
|
||||
def run_validator(self, *extra_args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(VALIDATOR),
|
||||
"--manifest",
|
||||
str(self.manifest_path),
|
||||
"--assets",
|
||||
str(self.assets_path),
|
||||
"--version",
|
||||
VERSION,
|
||||
"--repository",
|
||||
REPOSITORY,
|
||||
"--release-tag",
|
||||
RELEASE_TAG,
|
||||
*extra_args,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_rewrites_draft_urls_to_stable_release_tag(self) -> None:
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||
urls = {entry["url"] for entry in manifest["platforms"].values()}
|
||||
self.assertTrue(urls)
|
||||
self.assertTrue(
|
||||
all("/releases/download/v0.3.1/" in url for url in urls),
|
||||
urls,
|
||||
)
|
||||
self.assertTrue(all("untagged-" not in url for url in urls), urls)
|
||||
|
||||
def test_rejects_draft_urls_without_rewrite(self) -> None:
|
||||
result = self.run_validator()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("stable release tag download URL", result.stderr)
|
||||
|
||||
def test_repairs_stale_urls_after_release_is_published(self) -> None:
|
||||
assets = json.loads(self.assets_path.read_text(encoding="utf-8"))
|
||||
for asset in assets:
|
||||
asset["browser_download_url"] = (
|
||||
f"https://github.com/{REPOSITORY}/releases/download/"
|
||||
f"{RELEASE_TAG}/{asset['name']}"
|
||||
)
|
||||
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
|
||||
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertTrue(
|
||||
all(
|
||||
"/releases/download/v0.3.1/" in entry["url"]
|
||||
for entry in manifest["platforms"].values()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ import binascii
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
|
||||
REQUIRED_PLATFORMS = {
|
||||
@@ -48,10 +49,20 @@ def validate_signature(platform: str, value: Any) -> None:
|
||||
fail(f"{platform} signature is unexpectedly short")
|
||||
|
||||
|
||||
def canonical_asset_url(repository: str, release_tag: str, asset_name: str) -> str:
|
||||
return (
|
||||
f"https://github.com/{repository}/releases/download/"
|
||||
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
|
||||
)
|
||||
|
||||
|
||||
def rewrite_download_urls(
|
||||
manifest_path: Path,
|
||||
manifest: dict[str, Any],
|
||||
assets_by_url: dict[str, dict[str, Any]],
|
||||
assets_by_name: dict[str, dict[str, Any]],
|
||||
repository: str,
|
||||
release_tag: str,
|
||||
) -> int:
|
||||
platforms = manifest.get("platforms")
|
||||
if not isinstance(platforms, dict):
|
||||
@@ -65,11 +76,14 @@ def rewrite_download_urls(
|
||||
if not isinstance(current_url, str):
|
||||
fail(f"{platform} has an invalid download URL")
|
||||
asset = assets_by_url.get(current_url)
|
||||
if asset is None:
|
||||
parsed_url = urlparse(current_url)
|
||||
asset_name = unquote(parsed_url.path.rsplit("/", 1)[-1])
|
||||
if parsed_url.scheme == "https" and parsed_url.netloc == "github.com":
|
||||
asset = assets_by_name.get(asset_name)
|
||||
if asset is None:
|
||||
fail(f"{platform} URL does not point to an asset in this release")
|
||||
download_url = asset.get("browser_download_url")
|
||||
if not isinstance(download_url, str) or not download_url.startswith("https://"):
|
||||
fail(f"{platform} asset has no public download URL")
|
||||
download_url = canonical_asset_url(repository, release_tag, asset["name"])
|
||||
if current_url != download_url:
|
||||
entry["url"] = download_url
|
||||
rewritten += 1
|
||||
@@ -86,9 +100,16 @@ def main() -> None:
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--assets", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--release-tag", required=True)
|
||||
parser.add_argument("--rewrite-download-urls", action="store_true")
|
||||
parser.add_argument("--require-signature-assets", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repository_parts = args.repository.split("/")
|
||||
if len(repository_parts) != 2 or not all(repository_parts):
|
||||
fail(f"invalid GitHub repository {args.repository!r}")
|
||||
|
||||
manifest = load_json(args.manifest)
|
||||
assets = load_json(args.assets)
|
||||
if not isinstance(manifest, dict):
|
||||
@@ -101,15 +122,20 @@ def main() -> None:
|
||||
)
|
||||
|
||||
asset_names: set[str] = set()
|
||||
assets_by_name: dict[str, dict[str, Any]] = {}
|
||||
assets_by_url: dict[str, dict[str, Any]] = {}
|
||||
for asset in assets:
|
||||
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
|
||||
fail("release assets response contains an invalid entry")
|
||||
asset_names.add(asset["name"])
|
||||
assets_by_name[asset["name"]] = asset
|
||||
for field in ("url", "browser_download_url"):
|
||||
value = asset.get(field)
|
||||
if isinstance(value, str) and value:
|
||||
assets_by_url[value] = asset
|
||||
assets_by_url[
|
||||
canonical_asset_url(args.repository, args.release_tag, asset["name"])
|
||||
] = asset
|
||||
|
||||
if "latest.json" not in asset_names:
|
||||
fail("the draft release does not contain latest.json")
|
||||
@@ -119,7 +145,14 @@ def main() -> None:
|
||||
fail("latest.json has no platforms object")
|
||||
|
||||
if args.rewrite_download_urls:
|
||||
rewritten = rewrite_download_urls(args.manifest, manifest, assets_by_url)
|
||||
rewritten = rewrite_download_urls(
|
||||
args.manifest,
|
||||
manifest,
|
||||
assets_by_url,
|
||||
assets_by_name,
|
||||
args.repository,
|
||||
args.release_tag,
|
||||
)
|
||||
print(f"Rewrote {rewritten} updater asset URLs to public download URLs.")
|
||||
|
||||
for platform, entry in platforms.items():
|
||||
@@ -132,8 +165,13 @@ def main() -> None:
|
||||
asset = assets_by_url.get(url)
|
||||
if asset is None:
|
||||
fail(f"{platform} URL does not point to an asset in this release")
|
||||
if url != asset.get("browser_download_url"):
|
||||
fail(f"{platform} URL points to GitHub metadata instead of the asset download")
|
||||
expected_url = canonical_asset_url(
|
||||
args.repository,
|
||||
args.release_tag,
|
||||
asset["name"],
|
||||
)
|
||||
if url != expected_url:
|
||||
fail(f"{platform} URL does not use the stable release tag download URL")
|
||||
|
||||
for platform, suffix in REQUIRED_PLATFORMS.items():
|
||||
entry = platforms.get(platform)
|
||||
@@ -144,12 +182,18 @@ def main() -> None:
|
||||
asset_name = asset["name"]
|
||||
if not asset_name.endswith(suffix):
|
||||
fail(f"{platform} points to {asset_name!r}, expected a {suffix} updater")
|
||||
if f"{asset_name}.sig" not in asset_names:
|
||||
if args.require_signature_assets and f"{asset_name}.sig" not in asset_names:
|
||||
fail(f"signature asset is missing for {asset_name}")
|
||||
|
||||
signature_status = (
|
||||
"signature assets required"
|
||||
if args.require_signature_assets
|
||||
else "signature assets optional"
|
||||
)
|
||||
print(
|
||||
f"Validated updater {args.version}: "
|
||||
f"{len(REQUIRED_PLATFORMS)} platform installers and signatures are complete."
|
||||
f"{len(REQUIRED_PLATFORMS)} platform installers are complete; "
|
||||
f"{signature_status}."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user