mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a309f402c1 | ||
|
|
d7610f9b12 | ||
|
|
e4822237fb | ||
|
|
5d18b2555e | ||
|
|
2ace729801 | ||
|
|
488f27e095 | ||
|
|
826a142fc0 | ||
|
|
d1b40285dd |
@@ -223,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
|
||||
|
||||
+53
-1
@@ -2,7 +2,59 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [Unreleased]
|
||||
## [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
|
||||
|
||||
|
||||
+10
-4
@@ -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>
|
||||
@@ -238,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
|
||||
|
||||
@@ -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>
|
||||
@@ -230,7 +235,8 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
> [!NOTE]
|
||||
> 启用新的第三方供应商后,新建或重新打开 Codex 会话即可使用新的中转,不需要重启整个 Codex 客户端。
|
||||
|
||||
- 保存多个第三方供应商,随时查看当前正在使用哪一个
|
||||
- 保存多个可命名的官方 Codex 登录配置与第三方供应商,随时查看当前正在使用哪一个
|
||||
- 点击“复制”直接新增独立副本,无需确认或进入编辑页;名称可稍后修改
|
||||
- 切换前可检测连接,并可获取模型进行测试
|
||||
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
|
||||
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.16",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -490,7 +490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.10"
|
||||
version = "0.3.16"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.10"
|
||||
version = "0.3.16"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 1;
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 4;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
@@ -193,6 +193,32 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
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()))?;
|
||||
@@ -319,7 +345,10 @@ mod tests {
|
||||
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"), 1);
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&path).expect("replace initialized database");
|
||||
@@ -330,11 +359,77 @@ mod tests {
|
||||
.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"), 1);
|
||||
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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod backups;
|
||||
mod ccswitch;
|
||||
mod config_migration;
|
||||
mod constants;
|
||||
mod context_config;
|
||||
mod desktop_lifecycle;
|
||||
mod error;
|
||||
mod file_io;
|
||||
@@ -33,6 +34,7 @@ mod sqlite_utils;
|
||||
mod state;
|
||||
mod toml_utils;
|
||||
mod updates;
|
||||
mod usage;
|
||||
|
||||
use backups::{
|
||||
action_backup_root, backups, create_backup, validate_backup_codex_dir, BackupEntry, BackupMeta,
|
||||
@@ -70,26 +72,35 @@ use prompts::{
|
||||
prompt_content_source_urls, stable_remote_prompt_id, stale_cached_prompt_ids,
|
||||
uninstall_managed_agents_block, CachedBuiltinPrompt, GithubContentEntry,
|
||||
};
|
||||
use providers::official_profiles::{
|
||||
delete_official_profile_inner, duplicate_official_profile_inner, get_official_profile_inner,
|
||||
list_official_profiles_inner, save_official_profile_inner, switch_official_profile_inner,
|
||||
OfficialProfileActionResult, OfficialProfileDetail, OfficialProfileInput,
|
||||
OfficialProfileSummary,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use providers::{
|
||||
build_ccswitch_codex_provider, canonical_provider_base_url, codex_sections_from_config,
|
||||
consolidate_legacy_provider_duplicates_on_connection, detected_live_custom_provider,
|
||||
is_official_ccswitch_row, list_saved_providers_on_connection, normalize_saved_provider,
|
||||
official_snapshot_path_for_test, provider_by_id_on_connection, provider_identity,
|
||||
provider_status_result, read_ccswitch_codex_rows, save_manual_provider_on_connection,
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
|
||||
get_official_config_draft_inner, is_official_ccswitch_row, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, official_snapshot_path_for_test, provider_by_id_on_connection,
|
||||
provider_identity, provider_status_result, read_ccswitch_codex_rows,
|
||||
reset_official_provider_inner, restore_official_provider_inner,
|
||||
save_manual_provider_on_connection, save_provider_toml_config_with_pre_persist,
|
||||
switch_official_provider_inner, switch_official_provider_with_pre_persist,
|
||||
switch_provider_with_pre_persist, upsert_ccswitch_provider_on_connection,
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
build_provider_toml_draft_inner, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
|
||||
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
|
||||
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
|
||||
save_provider_inner, save_provider_toml_config_inner, switch_official_provider_inner,
|
||||
switch_provider_inner, test_provider_connection_inner, ImportResult, OfficialAuthCandidate,
|
||||
OfficialConfigDraft, OfficialConfigInput, ProviderConnectionResult, ProviderInput,
|
||||
ProviderModelsResult, ProviderTomlInput, SavedProvider,
|
||||
build_provider_toml_draft_inner, clear_active_provider_on_connection,
|
||||
delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
import_ccswitch_codex_providers_inner, list_saved_providers_inner, open_store,
|
||||
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_inner,
|
||||
save_provider_toml_config_inner, switch_provider_inner, test_provider_connection_inner,
|
||||
ImportResult, OfficialAuthCandidate, OfficialConfigDraft, OfficialConfigInput,
|
||||
ProviderConnectionResult, ProviderInput, ProviderModelsResult, ProviderTomlInput,
|
||||
SavedProvider,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use sessions::{
|
||||
@@ -105,8 +116,9 @@ use sessions::{
|
||||
};
|
||||
use skills_mcp::{
|
||||
build_skills_mcp_state_inner, check_skill_updates_inner, import_existing_skills_mcp_inner,
|
||||
install_skill_zip_inner, preview_existing_skills_mcp_inner, toggle_codex_mcp_inner,
|
||||
toggle_codex_skill_inner, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
|
||||
install_skill_zip_inner, preview_existing_skills_mcp_inner, save_skills_mcp_note_inner,
|
||||
toggle_codex_mcp_inner, toggle_codex_skill_inner, SkillsMcpActionResult,
|
||||
SkillsMcpImportPreview, SkillsMcpState,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use skills_mcp::{
|
||||
@@ -154,6 +166,13 @@ struct AboutInfo {
|
||||
native_updater_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexDesktopRestartResult {
|
||||
app_name: String,
|
||||
was_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DiagnosticItem {
|
||||
@@ -473,6 +492,20 @@ async fn toggle_codex_mcp(
|
||||
.map_err(|e| CodexxError::Config(format!("切换 MCP 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_skills_mcp_note(
|
||||
config_dir: Option<String>,
|
||||
item_kind: String,
|
||||
id: String,
|
||||
note: String,
|
||||
) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
save_skills_mcp_note_inner(config_dir, item_kind, id, note)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存 Skills/MCP 备注失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_skill_zip(
|
||||
config_dir: Option<String>,
|
||||
@@ -578,6 +611,18 @@ async fn get_about_info(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取关于信息失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restart_codex_desktop() -> std::result::Result<CodexDesktopRestartResult, String> {
|
||||
let (app_name, was_running) =
|
||||
tauri::async_runtime::spawn_blocking(platform::restart_codex_desktop)
|
||||
.await
|
||||
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
|
||||
Ok(CodexDesktopRestartResult {
|
||||
app_name,
|
||||
was_running,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_saved_prompts() -> Result<Vec<SavedPrompt>> {
|
||||
tauri::async_runtime::spawn_blocking(list_saved_prompts_inner)
|
||||
@@ -852,6 +897,54 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
enum ActiveProviderSelectionUpdate {
|
||||
Set(String),
|
||||
ClearIfOfficial,
|
||||
}
|
||||
|
||||
fn finish_provider_selection(
|
||||
mut result: ActionResult,
|
||||
update: ActiveProviderSelectionUpdate,
|
||||
) -> ActionResult {
|
||||
if matches!(update, ActiveProviderSelectionUpdate::ClearIfOfficial)
|
||||
&& !result.state.is_official_provider
|
||||
{
|
||||
return result;
|
||||
}
|
||||
let codex_dir = PathBuf::from(&result.state.codex_dir);
|
||||
let refreshed_state = (|| -> Result<CodexState> {
|
||||
let conn = open_store()?;
|
||||
match update {
|
||||
ActiveProviderSelectionUpdate::Set(provider_id) => {
|
||||
let saved = providers::list_saved_providers_on_connection(&conn)?;
|
||||
let matches = providers::detected_live_custom_provider(&codex_dir)?
|
||||
.map(|live| providers::matching_saved_provider_ids_for_live(&live, &saved))
|
||||
.unwrap_or_default();
|
||||
// A detected UI row can carry a provisional ID. Adoption may
|
||||
// allocate a different ID, so only honor an explicit selection
|
||||
// that actually matches the now-active saved configuration.
|
||||
if matches.contains(&provider_id) {
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, &provider_id)?;
|
||||
}
|
||||
}
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial => {
|
||||
clear_active_provider_on_connection(&conn, &codex_dir)?;
|
||||
}
|
||||
}
|
||||
drop(conn);
|
||||
build_state_after_migration(codex_dir)
|
||||
})();
|
||||
match refreshed_state {
|
||||
Ok(state) => result.state = state,
|
||||
Err(error) => {
|
||||
result
|
||||
.message
|
||||
.push_str(&format!(";当前供应商状态记录失败:{error}"));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn build_provider_toml_draft(
|
||||
provider: SavedProvider,
|
||||
@@ -875,14 +968,57 @@ async fn save_provider(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn duplicate_provider(
|
||||
config_dir: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
) -> Result<providers::DuplicateProviderResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::duplicate_provider_inner(config_dir, provider_id, provider_name)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("复制供应商失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_codex_context_window(
|
||||
config_text: String,
|
||||
enabled: Option<bool>,
|
||||
previous_values: Option<context_config::ContextWindowValues>,
|
||||
) -> Result<context_config::ContextWindowConfig> {
|
||||
context_config::update_codex_context_window_inner(config_text, enabled, previous_values)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_usage_statistics(
|
||||
config_dir: Option<String>,
|
||||
range: Option<String>,
|
||||
model: Option<String>,
|
||||
force_refresh: Option<bool>,
|
||||
) -> Result<usage::UsageStatistics> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
usage::get_usage_statistics_inner(config_dir, range, model, force_refresh)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取用量统计失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_active_provider(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_active_provider_inner(provider, config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = save_active_provider_inner(provider, config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -906,31 +1042,120 @@ fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || switch_official_provider_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_config_draft(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Option<OfficialConfigDraft>> {
|
||||
tauri::async_runtime::spawn_blocking(move || get_official_config_draft_inner(config_dir))
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let detail = get_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(Some(OfficialConfigDraft {
|
||||
auth_json: detail.auth_json,
|
||||
config_text: detail.config_text,
|
||||
model: detail.profile.model,
|
||||
source: detail.source,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_official_profiles(config_dir: Option<String>) -> Result<Vec<OfficialProfileSummary>> {
|
||||
tauri::async_runtime::spawn_blocking(move || list_official_profiles_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
|
||||
.map_err(|error| CodexxError::Config(format!("读取官方配置列表失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_official_profile(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialProfileDetail> {
|
||||
tauri::async_runtime::spawn_blocking(move || get_official_profile_inner(config_dir, profile_id))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_official_profile(input: OfficialProfileInput) -> Result<OfficialProfileActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_official_profile_inner(input))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn duplicate_official_profile(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
provider_name: Option<String>,
|
||||
) -> Result<OfficialProfileActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
duplicate_official_profile_inner(config_dir, profile_id, provider_name)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("复制官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_official_profile(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("切换官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_official_profile(config_dir: Option<String>, profile_id: String) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
delete_official_profile_inner(config_dir, profile_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("删除官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_official_provider_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result =
|
||||
providers::official_profiles::restore_default_official_profile_inner(config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
reset_official_provider_inner(input.config_dir, input.model, input.config_text)
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
@@ -939,12 +1164,16 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
|
||||
#[tauri::command]
|
||||
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
save_official_config_inner(
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -1124,10 +1353,21 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_toml_config(input: ProviderTomlInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_provider_toml_config_inner(input))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
|
||||
async fn save_provider_toml_config(
|
||||
input: ProviderTomlInput,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1152,9 +1392,18 @@ async fn fetch_provider_models(
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || switch_provider_inner(input))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1341,12 +1590,14 @@ pub fn run() {
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_about_info,
|
||||
restart_codex_desktop,
|
||||
check_app_update,
|
||||
get_skills_mcp_state,
|
||||
preview_existing_skills_mcp,
|
||||
import_existing_skills_mcp,
|
||||
toggle_codex_skill,
|
||||
toggle_codex_mcp,
|
||||
save_skills_mcp_note,
|
||||
install_skill_zip,
|
||||
check_skill_updates,
|
||||
get_startup_diagnostics,
|
||||
@@ -1367,11 +1618,20 @@ pub fn run() {
|
||||
list_saved_providers,
|
||||
build_provider_toml_draft,
|
||||
save_provider,
|
||||
duplicate_provider,
|
||||
update_codex_context_window,
|
||||
get_usage_statistics,
|
||||
save_active_provider,
|
||||
delete_saved_provider,
|
||||
get_codex_state,
|
||||
switch_official_provider,
|
||||
get_official_config_draft,
|
||||
list_official_profiles,
|
||||
get_official_profile,
|
||||
save_official_profile,
|
||||
duplicate_official_profile,
|
||||
switch_official_profile,
|
||||
delete_official_profile,
|
||||
restore_official_provider,
|
||||
reset_official_provider,
|
||||
save_official_config,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,46 @@ const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
|
||||
&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT-Desktop"];
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const MACOS_CODEX_APP_NAMES: &[&str] = &[
|
||||
"Codex.app",
|
||||
"OpenAI Codex.app",
|
||||
"OpenAI.Codex.app",
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
];
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const MACOS_CODEX_BUNDLE_IDS: &[&str] = &["com.openai.codex", "com.openai.codex.beta"];
|
||||
|
||||
const CODEX_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PROGRAM_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROGRAM_POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||
const CHILD_TERMINATION_GRACE: Duration = Duration::from_millis(250);
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
const CODEX_DESKTOP_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
const CODEX_DESKTOP_QUIT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
#[cfg(target_os = "macos")]
|
||||
const CODEX_DESKTOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const WINDOWS_RESTART_RESULT_PREFIX: &str = "CODEX_X_DESKTOP_RESTART";
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MacosAppMetadata {
|
||||
bundle_id: String,
|
||||
executable: String,
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Debug, Clone)]
|
||||
struct MacosCodexApp {
|
||||
bundle_id: String,
|
||||
bundle_path: PathBuf,
|
||||
executable_path: PathBuf,
|
||||
app_name: String,
|
||||
}
|
||||
|
||||
static CODEX_VERSION: OnceLock<String> = OnceLock::new();
|
||||
|
||||
@@ -172,8 +207,13 @@ fn receive_output(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
|
||||
let timeout = remaining_timeout(deadline, PROGRAM_TIMEOUT)?;
|
||||
fn run_program_with_timeout(
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
deadline: Option<Instant>,
|
||||
maximum: Duration,
|
||||
) -> Option<Output> {
|
||||
let timeout = remaining_timeout(deadline, maximum)?;
|
||||
let command_deadline = Instant::now().checked_add(timeout)?;
|
||||
let mut command = program_command(program, args);
|
||||
command
|
||||
@@ -222,6 +262,10 @@ fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Opti
|
||||
})
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str], deadline: Option<Instant>) -> Option<Output> {
|
||||
run_program_with_timeout(program, args, deadline, PROGRAM_TIMEOUT)
|
||||
}
|
||||
|
||||
fn command_version(program: &Path, deadline: Option<Instant>) -> Option<String> {
|
||||
run_program(program, &["--version"], deadline)
|
||||
.and_then(version_from_output)
|
||||
@@ -550,47 +594,90 @@ fn windows_where_candidates(_deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn macos_codex_app_paths(home: &Path) -> Vec<PathBuf> {
|
||||
[PathBuf::from("/Applications"), home.join("Applications")]
|
||||
.into_iter()
|
||||
.flat_map(|root| {
|
||||
MACOS_CODEX_APP_NAMES
|
||||
.iter()
|
||||
.map(move |name| root.join(name))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn macos_app_metadata_from_plist(plist: &str) -> Option<MacosAppMetadata> {
|
||||
Some(MacosAppMetadata {
|
||||
bundle_id: plist_string_value(plist, "CFBundleIdentifier")?,
|
||||
executable: plist_string_value(plist, "CFBundleExecutable")?,
|
||||
display_name: plist_string_value(plist, "CFBundleDisplayName")
|
||||
.or_else(|| plist_string_value(plist, "CFBundleName")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn is_supported_macos_codex_bundle_id(bundle_id: &str) -> bool {
|
||||
MACOS_CODEX_BUNDLE_IDS
|
||||
.iter()
|
||||
.any(|supported| bundle_id.eq_ignore_ascii_case(supported))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn parse_macos_main_process_ids(output: &str, executable_path: &Path) -> Vec<u32> {
|
||||
let executable = executable_path.to_string_lossy();
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim_start();
|
||||
let pid_end = line.find(char::is_whitespace)?;
|
||||
let pid = line[..pid_end].parse::<u32>().ok()?;
|
||||
let command = line[pid_end..].trim_start();
|
||||
let matches = command == executable
|
||||
|| command
|
||||
.strip_prefix(executable.as_ref())
|
||||
.is_some_and(|rest| rest.chars().next().is_some_and(char::is_whitespace));
|
||||
matches.then_some(pid)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
|
||||
for name in [
|
||||
"Codex.app",
|
||||
"OpenAI Codex.app",
|
||||
"OpenAI.Codex.app",
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
] {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
let app = root.join(name);
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_name = if name == "ChatGPT.app" {
|
||||
"ChatGPT app"
|
||||
} else {
|
||||
"Codex app"
|
||||
};
|
||||
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
|
||||
let app = app.to_str()?;
|
||||
let output = run_program(
|
||||
Path::new("mdls"),
|
||||
&["-name", "kMDItemVersion", "-raw", app],
|
||||
deadline,
|
||||
)?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}) {
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
return Some(format!("{app_name} {version}"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{app_name} installed"));
|
||||
for app in macos_codex_app_paths(&home) {
|
||||
if deadline_expired(deadline) {
|
||||
return None;
|
||||
}
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_name = if app
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == "ChatGPT.app")
|
||||
{
|
||||
"ChatGPT app"
|
||||
} else {
|
||||
"Codex app"
|
||||
};
|
||||
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
|
||||
let app = app.to_str()?;
|
||||
let output = run_program(
|
||||
Path::new("mdls"),
|
||||
&["-name", "kMDItemVersion", "-raw", app],
|
||||
deadline,
|
||||
)?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}) {
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
return Some(format!("{app_name} {version}"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{app_name} installed"));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -611,11 +698,365 @@ fn plist_string_value(plist: &str, key: &str) -> Option<String> {
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_plist_raw_value(app: &Path, key: &str, deadline: Option<Instant>) -> Option<String> {
|
||||
let plist_path = app.join("Contents/Info.plist");
|
||||
if let Ok(plist) = fs::read_to_string(&plist_path) {
|
||||
if let Some(value) = plist_string_value(&plist, key) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
let plist_path = plist_path.to_str()?;
|
||||
let output = run_program(
|
||||
Path::new("/usr/bin/plutil"),
|
||||
&["-extract", key, "raw", "-o", "-", plist_path],
|
||||
deadline,
|
||||
)?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_codex_apps(deadline: Option<Instant>) -> Result<Vec<MacosCodexApp>, String> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let mut apps = Vec::new();
|
||||
for app in macos_codex_app_paths(&home) {
|
||||
if deadline_expired(deadline) {
|
||||
return Err("检测 Codex/ChatGPT 客户端超时".to_string());
|
||||
}
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let plist = fs::read_to_string(app.join("Contents/Info.plist")).ok();
|
||||
let metadata = plist.as_deref().and_then(macos_app_metadata_from_plist);
|
||||
let bundle_id = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.bundle_id.clone())
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleIdentifier", deadline));
|
||||
let executable = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.executable.clone())
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleExecutable", deadline));
|
||||
let Some(bundle_id) = bundle_id.filter(|value| is_supported_macos_codex_bundle_id(value))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(executable) = executable.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& !value.contains('/')
|
||||
&& !value.contains('\\')
|
||||
&& !value.contains('\0')
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let executable_path = app.join("Contents/MacOS").join(executable);
|
||||
if !executable_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let executable_path = executable_path.canonicalize().unwrap_or(executable_path);
|
||||
let display_name = metadata
|
||||
.and_then(|metadata| metadata.display_name)
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleDisplayName", deadline))
|
||||
.or_else(|| macos_plist_raw_value(&app, "CFBundleName", deadline));
|
||||
let app_name = if display_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.to_ascii_lowercase().contains("chatgpt"))
|
||||
|| app
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.to_ascii_lowercase().contains("chatgpt"))
|
||||
{
|
||||
"ChatGPT"
|
||||
} else {
|
||||
"Codex"
|
||||
};
|
||||
let bundle_path = app.canonicalize().unwrap_or(app);
|
||||
apps.push(MacosCodexApp {
|
||||
bundle_id,
|
||||
bundle_path,
|
||||
executable_path,
|
||||
app_name: app_name.to_string(),
|
||||
});
|
||||
}
|
||||
if apps.is_empty() {
|
||||
Err("未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())
|
||||
} else {
|
||||
Ok(apps)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_main_process_ids(
|
||||
executable_path: &Path,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/bin/ps"),
|
||||
&["-axo", "pid=,command="],
|
||||
deadline,
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or_else(|| "读取 Codex/ChatGPT 客户端进程失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"读取 Codex/ChatGPT 客户端进程失败".to_string()
|
||||
} else {
|
||||
format!("读取 Codex/ChatGPT 客户端进程失败: {message}")
|
||||
});
|
||||
}
|
||||
Ok(parse_macos_main_process_ids(
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
executable_path,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_codex_app(app: &MacosCodexApp) -> Result<(), String> {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 启动超时时间失败".to_string())?;
|
||||
let bundle_path = app
|
||||
.bundle_path
|
||||
.to_str()
|
||||
.ok_or_else(|| "Codex/ChatGPT 客户端路径不是有效文本".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/usr/bin/open"),
|
||||
&["-a", bundle_path],
|
||||
Some(deadline),
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or_else(|| "启动 Codex/ChatGPT 客户端失败或超时".to_string())?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if message.is_empty() {
|
||||
"启动 Codex/ChatGPT 客户端失败".to_string()
|
||||
} else {
|
||||
format!("启动 Codex/ChatGPT 客户端失败: {message}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
let detection_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 检测超时时间失败".to_string())?;
|
||||
let apps = macos_codex_apps(Some(detection_deadline))?;
|
||||
let mut first_installed = None;
|
||||
let mut running_app = None;
|
||||
for app in apps {
|
||||
let is_running =
|
||||
!macos_main_process_ids(&app.executable_path, Some(detection_deadline))?.is_empty();
|
||||
if is_running {
|
||||
running_app = Some(app);
|
||||
break;
|
||||
}
|
||||
if first_installed.is_none() {
|
||||
first_installed = Some(app);
|
||||
}
|
||||
}
|
||||
let was_running = running_app.is_some();
|
||||
let app = running_app
|
||||
.or(first_installed)
|
||||
.ok_or_else(|| "未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())?;
|
||||
if !was_running {
|
||||
launch_macos_codex_app(&app)?;
|
||||
return Ok((app.app_name, false));
|
||||
}
|
||||
|
||||
let quit_script = format!("tell application id \"{}\" to quit", app.bundle_id);
|
||||
let command_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 退出命令超时时间失败".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/usr/bin/osascript"),
|
||||
&["-e", quit_script.as_str()],
|
||||
Some(command_deadline),
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or_else(|| "请求 Codex/ChatGPT 客户端正常退出失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"请求 Codex/ChatGPT 客户端正常退出失败".to_string()
|
||||
} else {
|
||||
format!("请求 Codex/ChatGPT 客户端正常退出失败: {message}")
|
||||
});
|
||||
}
|
||||
|
||||
let quit_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_QUIT_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 退出等待时间失败".to_string())?;
|
||||
loop {
|
||||
if macos_main_process_ids(&app.executable_path, Some(quit_deadline))?.is_empty() {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= quit_deadline {
|
||||
return Err("Codex/ChatGPT 客户端未在限定时间内正常退出,已取消重新启动".to_string());
|
||||
}
|
||||
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
launch_macos_codex_app(&app)?;
|
||||
Ok((app.app_name, true))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn parse_windows_restart_result(output: &str) -> Option<(String, bool)> {
|
||||
output.lines().rev().find_map(|line| {
|
||||
let payload = line
|
||||
.trim()
|
||||
.strip_prefix(WINDOWS_RESTART_RESULT_PREFIX)?
|
||||
.strip_prefix('\t')?;
|
||||
let mut fields = payload.split('\t');
|
||||
let app_name = fields.next()?.trim();
|
||||
let was_running = match fields.next()?.trim() {
|
||||
"0" => false,
|
||||
"1" => true,
|
||||
_ => return None,
|
||||
};
|
||||
if fields.next().is_some() || !matches!(app_name, "Codex" | "ChatGPT") {
|
||||
return None;
|
||||
}
|
||||
Some((app_name.to_string(), was_running))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_restart_script() -> String {
|
||||
let identities = WINDOWS_CODEX_PACKAGE_IDENTITIES
|
||||
.iter()
|
||||
.map(|identity| format!("'{}'", identity.replace('\'', "''")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
r#"
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$utf8 = [System.Text.UTF8Encoding]::new($false)
|
||||
[Console]::OutputEncoding = $utf8
|
||||
$OutputEncoding = $utf8
|
||||
$identities = @(__IDENTITIES__)
|
||||
$packages = @(Get-AppxPackage |
|
||||
Where-Object { $identities -contains $_.Name } |
|
||||
Sort-Object -Property Version -Descending |
|
||||
Select-Object -Unique)
|
||||
if ($packages.Count -eq 0) { throw 'Supported Codex/ChatGPT Appx package was not found.' }
|
||||
|
||||
function Get-CodexDesktopProcesses([string]$root) {
|
||||
$appRoot = $root + 'app\'
|
||||
$resourcesRoot = $appRoot + 'resources\'
|
||||
@(Get-Process -ErrorAction SilentlyContinue | Where-Object {
|
||||
try {
|
||||
$processPath = [IO.Path]::GetFullPath($_.MainModule.FileName)
|
||||
$executableName = [IO.Path]::GetFileName($processPath)
|
||||
($executableName -ieq 'Codex.exe' -or $executableName -ieq 'ChatGPT.exe') -and
|
||||
$processPath.StartsWith($appRoot, [StringComparison]::OrdinalIgnoreCase) -and
|
||||
-not $processPath.StartsWith($resourcesRoot, [StringComparison]::OrdinalIgnoreCase)
|
||||
} catch {
|
||||
$false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$package = $null
|
||||
$installRoot = $null
|
||||
$processes = @()
|
||||
foreach ($candidate in $packages) {
|
||||
$candidateRoot = [IO.Path]::GetFullPath([string]$candidate.InstallLocation).TrimEnd('\') + '\'
|
||||
$candidateProcesses = @(Get-CodexDesktopProcesses $candidateRoot)
|
||||
if ($candidateProcesses.Count -gt 0) {
|
||||
$package = $candidate
|
||||
$installRoot = $candidateRoot
|
||||
$processes = $candidateProcesses
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($null -eq $package) {
|
||||
$package = $packages[0]
|
||||
$installRoot = [IO.Path]::GetFullPath([string]$package.InstallLocation).TrimEnd('\') + '\'
|
||||
$processes = @(Get-CodexDesktopProcesses $installRoot)
|
||||
}
|
||||
|
||||
$manifest = Get-AppxPackageManifest -Package $package.PackageFullName
|
||||
$application = @($manifest.Package.Applications.Application) | Select-Object -First 1
|
||||
$applicationId = [string]$application.Id
|
||||
$packageFamily = [string]$package.PackageFamilyName
|
||||
if ([string]::IsNullOrWhiteSpace($applicationId) -or [string]::IsNullOrWhiteSpace($packageFamily)) {
|
||||
throw 'Codex/ChatGPT Appx package does not expose a launchable application id.'
|
||||
}
|
||||
|
||||
$wasRunning = $processes.Count -gt 0
|
||||
if ($wasRunning) {
|
||||
foreach ($process in $processes) {
|
||||
Stop-Process -InputObject $process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$quitDeadline = [DateTime]::UtcNow.AddSeconds(8)
|
||||
do {
|
||||
$remaining = @(Get-CodexDesktopProcesses $installRoot)
|
||||
if ($remaining.Count -eq 0) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $quitDeadline)
|
||||
$remaining = @(Get-CodexDesktopProcesses $installRoot)
|
||||
if ($remaining.Count -gt 0) {
|
||||
throw 'Codex/ChatGPT background processes could not be stopped before the timeout; relaunch was cancelled.'
|
||||
}
|
||||
}
|
||||
|
||||
$aumid = $packageFamily + '!' + $applicationId
|
||||
Start-Process -FilePath 'explorer.exe' -ArgumentList ('shell:AppsFolder\' + $aumid) | Out-Null
|
||||
$appName = if ($package.Name -eq 'OpenAI.ChatGPT-Desktop') { 'ChatGPT' } else { 'Codex' }
|
||||
$runningFlag = if ($wasRunning) { '1' } else { '0' }
|
||||
[Console]::Out.WriteLine('__RESULT_PREFIX__' + "`t" + $appName + "`t" + $runningFlag)
|
||||
"#
|
||||
.replace("__IDENTITIES__", &identities)
|
||||
.replace("__RESULT_PREFIX__", WINDOWS_RESTART_RESULT_PREFIX)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
let script = windows_restart_script();
|
||||
let timeout = CODEX_DESKTOP_QUIT_TIMEOUT + CODEX_DESKTOP_COMMAND_TIMEOUT;
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 重启超时时间失败".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("powershell.exe"),
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script.as_str(),
|
||||
],
|
||||
Some(deadline),
|
||||
timeout,
|
||||
)
|
||||
.ok_or_else(|| "重启 Codex/ChatGPT 客户端失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"重启 Codex/ChatGPT 客户端失败".to_string()
|
||||
} else {
|
||||
format!("重启 Codex/ChatGPT 客户端失败: {message}")
|
||||
});
|
||||
}
|
||||
parse_windows_restart_result(&String::from_utf8_lossy(&output.stdout))
|
||||
.ok_or_else(|| "无法确认 Codex/ChatGPT 客户端的重启结果".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_app_version(deadline: Option<Instant>) -> Option<String> {
|
||||
let mut roots = Vec::new();
|
||||
@@ -680,6 +1121,11 @@ fn windows_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
Err("当前平台暂不支持重启 Codex/ChatGPT 桌面客户端".to_string())
|
||||
}
|
||||
|
||||
fn path_codex_candidates(deadline: Option<Instant>) -> Vec<PathBuf> {
|
||||
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
|
||||
.into_iter()
|
||||
@@ -805,8 +1251,10 @@ pub fn detect_codex_version() -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cached_codex_version, latest_windows_package_version, plist_string_value, run_program,
|
||||
version_line, visit_named_files,
|
||||
cached_codex_version, is_supported_macos_codex_bundle_id, latest_windows_package_version,
|
||||
macos_app_metadata_from_plist, macos_codex_app_paths, parse_macos_main_process_ids,
|
||||
parse_windows_restart_result, plist_string_value, run_program, version_line,
|
||||
visit_named_files, windows_restart_script,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -943,6 +1391,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restart_result_parser_ignores_noise_and_reads_state() {
|
||||
let output = "PowerShell startup text\nCODEX_X_DESKTOP_RESTART\tChatGPT\t1\n";
|
||||
assert_eq!(
|
||||
parse_windows_restart_result(output),
|
||||
Some(("ChatGPT".to_string(), true))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_windows_restart_result("CODEX_X_DESKTOP_RESTART\tCodex\t0\n"),
|
||||
Some(("Codex".to_string(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_windows_restart_result("CODEX_X_DESKTOP_RESTART\tOther\t1\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restart_script_only_stops_processes_in_the_selected_package_directory() {
|
||||
let script = windows_restart_script();
|
||||
assert!(script.contains("$processPath.StartsWith($appRoot"));
|
||||
assert!(script.contains("$executableName -ieq 'Codex.exe'"));
|
||||
assert!(script.contains("$executableName -ieq 'ChatGPT.exe'"));
|
||||
assert!(script.contains("-not $processPath.StartsWith($resourcesRoot"));
|
||||
assert!(script.contains("Stop-Process -InputObject $process -Force"));
|
||||
assert!(script.contains("[Console]::OutputEncoding = $utf8"));
|
||||
assert!(script.contains("shell:AppsFolder\\"));
|
||||
assert!(!script.contains("CloseMainWindow"));
|
||||
assert!(!script.contains("Get-Process -Name"));
|
||||
assert!(!script.contains("Stop-Process -Name"));
|
||||
assert!(!script.contains("taskkill"));
|
||||
assert!(!script.contains("ApplicationFrameHost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plist_parser_reads_codex_bundle_version() {
|
||||
let plist = r#"<plist><dict>
|
||||
@@ -954,4 +1436,53 @@ mod tests {
|
||||
Some("1.2026.204")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_app_metadata_parser_reads_exact_desktop_identity() {
|
||||
let plist = r#"<plist><dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ChatGPT</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ChatGPT</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.openai.codex</string>
|
||||
</dict></plist>"#;
|
||||
let metadata = macos_app_metadata_from_plist(plist).expect("parse app metadata");
|
||||
assert_eq!(metadata.bundle_id, "com.openai.codex");
|
||||
assert_eq!(metadata.executable, "ChatGPT");
|
||||
assert_eq!(metadata.display_name.as_deref(), Some("ChatGPT"));
|
||||
assert!(is_supported_macos_codex_bundle_id(&metadata.bundle_id));
|
||||
assert!(!is_supported_macos_codex_bundle_id("com.example.codex"));
|
||||
assert!(macos_app_metadata_from_plist(
|
||||
"<plist><dict><key>CFBundleIdentifier</key><string>com.openai.codex</string></dict></plist>"
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_app_candidates_reuse_supported_names_in_stable_order() {
|
||||
let paths = macos_codex_app_paths(Path::new("/Users/tester"));
|
||||
assert_eq!(
|
||||
paths.first(),
|
||||
Some(&Path::new("/Applications/Codex.app").to_path_buf())
|
||||
);
|
||||
assert_eq!(
|
||||
paths.last(),
|
||||
Some(&Path::new("/Users/tester/Applications/ChatGPT.app").to_path_buf())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_process_parser_only_matches_the_exact_main_executable() {
|
||||
let executable = Path::new("/Applications/ChatGPT.app/Contents/MacOS/ChatGPT");
|
||||
let output = " 101 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT\n\
|
||||
102 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT --started-from-launch-services\n\
|
||||
103 /Applications/ChatGPT.app/Contents/Frameworks/Codex (Renderer).app/Contents/MacOS/Codex (Renderer)\n\
|
||||
104 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT-helper\n\
|
||||
105 /Applications/Other.app/Contents/MacOS/ChatGPT\n";
|
||||
assert_eq!(
|
||||
parse_macos_main_process_ids(output, executable),
|
||||
vec![101, 102]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,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";
|
||||
@@ -305,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()))?;
|
||||
@@ -315,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
|
||||
@@ -678,6 +693,24 @@ fn mark_customized_prompt_statuses(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -733,7 +766,7 @@ pub(crate) fn builtin_prompt_status_inner() -> Result<Vec<BuiltinPromptStatus>>
|
||||
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 customized_ids = builtin_prompt_override_ids_inner()?;
|
||||
@@ -828,7 +861,10 @@ pub(crate) fn refresh_builtin_prompts_with_active(
|
||||
if catalog_confirmation_failed {
|
||||
mark_catalog_confirmation_failed(&mut statuses);
|
||||
}
|
||||
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
|
||||
// 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()
|
||||
@@ -849,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 {
|
||||
@@ -872,19 +907,6 @@ pub(crate) fn builtin_prompt_content(
|
||||
"本地已修改".to_string(),
|
||||
));
|
||||
}
|
||||
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)?;
|
||||
return Ok((
|
||||
filename.clone(),
|
||||
format!("./{filename}"),
|
||||
remote,
|
||||
"在线最新".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(cache) = cached {
|
||||
return Ok((
|
||||
cache.filename.clone(),
|
||||
@@ -894,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(),
|
||||
@@ -925,6 +949,91 @@ pub(crate) fn builtin_prompt_detail_inner(template_id: &str) -> Result<BuiltinPr
|
||||
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),
|
||||
@@ -939,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");
|
||||
@@ -950,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!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
experimental_bearer_token_from_doc, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, open_store, provider_template_from_document,
|
||||
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};
|
||||
@@ -267,16 +267,26 @@ fn ccswitch_provider_template(
|
||||
return None;
|
||||
}
|
||||
|
||||
doc["model_provider"] = value(provider_id);
|
||||
doc["model"] = value(model);
|
||||
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()?;
|
||||
let mut table = section.provider_table.clone();
|
||||
table["name"] = value(provider_name);
|
||||
table["base_url"] = value(section.base_url.clone());
|
||||
table["wire_api"] = value(section.wire_api.clone());
|
||||
table["requires_openai_auth"] = value(section.requires_openai_auth);
|
||||
providers.insert(provider_id, Item::Table(table));
|
||||
provider_template_from_document(&doc, provider_id, model).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(
|
||||
@@ -286,15 +296,15 @@ 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 = if row.name.trim().is_empty() {
|
||||
section.name.clone().unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
};
|
||||
let model = section
|
||||
.model
|
||||
let provider_name = section
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "gpt-5.5".to_string());
|
||||
.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),
|
||||
@@ -385,7 +395,6 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
match result.kind {
|
||||
ProviderUpsertKind::Added => added += 1,
|
||||
ProviderUpsertKind::Updated => updated += 1,
|
||||
ProviderUpsertKind::Merged => merged += 1,
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
@@ -517,14 +526,14 @@ mod tests {
|
||||
"auth": {"OPENAI_API_KEY": "sk-from-auth"},
|
||||
"config": r#"# keep-imported-comment
|
||||
model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
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 = "Stale name"
|
||||
name = "Sky2api"
|
||||
base_url = "https://proxy.example.com/v1/"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
@@ -563,7 +572,7 @@ command = "docs-server"
|
||||
.to_string();
|
||||
let row = CcSwitchCodexRow {
|
||||
id: "magicai-123".to_string(),
|
||||
name: " MagicAI ".to_string(),
|
||||
name: " Sky2_free ".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
};
|
||||
@@ -571,9 +580,9 @@ command = "docs-server"
|
||||
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, "MagicAI");
|
||||
assert_eq!(provider.provider_name, "Sky2api");
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
assert_eq!(provider.model, "gpt-5.5");
|
||||
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);
|
||||
@@ -587,11 +596,11 @@ command = "docs-server"
|
||||
assert_eq!(doc["notify"].as_array().map(|values| values.len()), Some(2));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["name"].as_str(),
|
||||
Some("MagicAI")
|
||||
Some("Sky2api")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://proxy.example.com/v1")
|
||||
Some("https://proxy.example.com/v1/")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
@@ -637,6 +646,29 @@ command = "docs-server"
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@ mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
mod official_auth;
|
||||
pub(crate) mod official_profiles;
|
||||
mod selection;
|
||||
mod store;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -24,21 +26,26 @@ pub(crate) use connection::{
|
||||
};
|
||||
pub(crate) use live::detected_live_custom_provider;
|
||||
pub(crate) use live::{
|
||||
build_provider_toml_draft_inner, delete_saved_provider_inner, reset_official_provider_inner,
|
||||
restore_official_provider_inner, save_active_provider_inner, save_official_config_inner,
|
||||
save_provider_toml_config_inner, switch_official_provider_inner, switch_provider_inner,
|
||||
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::{
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
|
||||
switch_provider_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,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use official_auth::{capture_live_chatgpt_config, official_snapshot_path_for_test};
|
||||
pub(crate) use official_auth::{
|
||||
document_is_official, get_official_config_draft_inner, official_auth_available,
|
||||
OfficialConfigDraft,
|
||||
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::{
|
||||
@@ -47,13 +54,14 @@ pub(crate) use store::{
|
||||
};
|
||||
pub(crate) use store::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, experimental_bearer_token_from_doc, is_placeholder_provider,
|
||||
list_saved_providers_inner, list_saved_providers_on_connection,
|
||||
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,
|
||||
provider_template_from_document, reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_provider_inner, save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection,
|
||||
ProviderStoreRollback, ProviderUpsertKind, SavedProvider,
|
||||
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> {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::official_profiles::{
|
||||
has_named_profiles, selected_profile_id, DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
};
|
||||
use crate::backups::{action_backup_root, BackupMeta};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, json_err, parse_toml_document, write_private_json};
|
||||
use crate::file_io::{ensure_directory, io_err, parse_toml_document, write_private_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
@@ -27,10 +30,10 @@ pub(crate) struct OfficialConfigCandidate {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialConfigDraft {
|
||||
auth_json: String,
|
||||
config_text: String,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
pub(crate) auth_json: String,
|
||||
pub(crate) config_text: String,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -60,11 +63,29 @@ fn canonical_identity(path: &Path) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn official_snapshot_path(codex_dir: &Path) -> Result<PathBuf> {
|
||||
let profile_id = selected_profile_id(codex_dir)?;
|
||||
official_snapshot_path_for_profile(codex_dir, &profile_id)
|
||||
}
|
||||
|
||||
pub(crate) fn official_snapshot_path_for_profile(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
if profile_id.trim().is_empty() {
|
||||
return Err(CodexxError::Config("官方配置 ID 不能为空".to_string()));
|
||||
}
|
||||
let identity = canonical_identity(codex_dir);
|
||||
let digest = Sha256::digest(identity.as_bytes());
|
||||
Ok(app_home()?
|
||||
.join("official-configs")
|
||||
.join(format!("{digest:x}.json")))
|
||||
let root = app_home()?.join("official-configs");
|
||||
if profile_id == DEFAULT_OFFICIAL_PROFILE_ID {
|
||||
// The built-in profile keeps the legacy recovery path so existing
|
||||
// installations can restore their saved official login unchanged.
|
||||
return Ok(root.join(format!("{digest:x}.json")));
|
||||
}
|
||||
let profile_digest = Sha256::digest(profile_id.as_bytes());
|
||||
Ok(root
|
||||
.join(format!("{digest:x}"))
|
||||
.join(format!("{profile_digest:x}.json")))
|
||||
}
|
||||
|
||||
fn value_has_material(value: &Value) -> bool {
|
||||
@@ -85,33 +106,61 @@ pub(crate) fn auth_value_has_material(value: &Value) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_chatgpt_auth(value: &Value) -> bool {
|
||||
// Older Codex releases wrote OAuth tokens without auth_mode. Accept that
|
||||
// unambiguous legacy shape, but reject any explicitly non-ChatGPT mode.
|
||||
let chatgpt_mode = match value.get("auth_mode") {
|
||||
None => true,
|
||||
Some(Value::String(mode)) => mode.eq_ignore_ascii_case("chatgpt"),
|
||||
Some(_) => false,
|
||||
};
|
||||
pub(crate) fn is_chatgpt_auth(value: &Value) -> bool {
|
||||
let mode = value.get("auth_mode").and_then(Value::as_str);
|
||||
let has_api_key = value
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|key| !key.trim().is_empty());
|
||||
chatgpt_mode
|
||||
&& !has_api_key
|
||||
&& value
|
||||
.get("tokens")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|tokens| {
|
||||
["access_token", "refresh_token", "id_token"]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
tokens
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
let has_bedrock_key = value.get("bedrock_api_key").is_some_and(value_has_material);
|
||||
if has_api_key || has_bedrock_key {
|
||||
return false;
|
||||
}
|
||||
|
||||
let has_tokens = value
|
||||
.get("tokens")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|tokens| {
|
||||
["access_token", "refresh_token", "id_token"]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
tokens
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|token| !token.trim().is_empty())
|
||||
})
|
||||
});
|
||||
let has_agent_identity = value.get("agent_identity").is_some_and(|identity| {
|
||||
identity.as_str().is_some_and(|jwt| !jwt.trim().is_empty())
|
||||
|| identity.as_object().is_some_and(|record| {
|
||||
["agent_runtime_id", "agent_private_key"].iter().all(|key| {
|
||||
record
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
})
|
||||
});
|
||||
let has_personal_access_token = value
|
||||
.get("personal_access_token")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|token| !token.trim().is_empty());
|
||||
|
||||
match mode {
|
||||
// This matches Codex AuthDotJson::resolved_mode: legacy token files
|
||||
// default to ChatGPT, while PAT infers its own mode. Agent Identity
|
||||
// requires an explicit auth_mode.
|
||||
None => has_tokens || has_personal_access_token,
|
||||
Some(mode)
|
||||
if mode.eq_ignore_ascii_case("chatgpt")
|
||||
|| mode.eq_ignore_ascii_case("chatgptAuthTokens") =>
|
||||
{
|
||||
has_tokens
|
||||
}
|
||||
Some(mode) if mode.eq_ignore_ascii_case("agentIdentity") => has_agent_identity,
|
||||
Some(mode) if mode.eq_ignore_ascii_case("personalAccessToken") => has_personal_access_token,
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_openai_api_key(value: &Value) -> bool {
|
||||
@@ -218,8 +267,12 @@ pub(crate) fn validate_official_config_text(
|
||||
));
|
||||
}
|
||||
remove_bearer_tokens(&mut doc);
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
doc["model"] = value(model);
|
||||
// A supplied complete TOML document is authoritative. The separate model
|
||||
// field only fills a missing value for compatibility with older clients.
|
||||
if string_value(&doc, "model").is_none() {
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
doc["model"] = value(model);
|
||||
}
|
||||
}
|
||||
let text = doc.to_string();
|
||||
let model = string_value(&doc, "model");
|
||||
@@ -274,7 +327,26 @@ fn write_snapshot(
|
||||
model: Option<String>,
|
||||
auth: Option<Value>,
|
||||
) -> Result<()> {
|
||||
let path = official_snapshot_path(codex_dir)?;
|
||||
let profile_id = selected_profile_id(codex_dir)?;
|
||||
write_official_profile_snapshot(codex_dir, &profile_id, config, model, auth)
|
||||
}
|
||||
|
||||
pub(crate) fn write_official_profile_snapshot(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
config: Option<String>,
|
||||
model: Option<String>,
|
||||
auth: Option<Value>,
|
||||
) -> Result<()> {
|
||||
if auth
|
||||
.as_ref()
|
||||
.is_some_and(|auth| !auth.is_object() || !auth_value_has_material(auth))
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
|
||||
));
|
||||
}
|
||||
let path = official_snapshot_path_for_profile(codex_dir, profile_id)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
@@ -297,14 +369,10 @@ pub(crate) fn save_official_config_snapshot(
|
||||
model: Option<String>,
|
||||
auth: &Value,
|
||||
) -> Result<()> {
|
||||
if !auth.is_object() || !auth_value_has_material(auth) {
|
||||
return Err(CodexxError::Config(
|
||||
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
|
||||
));
|
||||
}
|
||||
write_snapshot(codex_dir, config, model, Some(auth.clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mark_official_config_reset(
|
||||
codex_dir: &Path,
|
||||
config: Option<String>,
|
||||
@@ -316,16 +384,62 @@ pub(crate) fn mark_official_config_reset(
|
||||
pub(crate) fn capture_live_official_config_before_provider_switch(
|
||||
codex_dir: &Path,
|
||||
) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, |auth| {
|
||||
is_chatgpt_auth(auth) || has_openai_api_key(auth)
|
||||
})
|
||||
if !live_config_is_official(codex_dir)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let live_config = live_official_config_text(codex_dir)?;
|
||||
let live_model = live_config
|
||||
.as_deref()
|
||||
.map(|text| model_from_config(&config_path(codex_dir), text))
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let has_explicit_official_route = live_config
|
||||
.as_deref()
|
||||
.map(|text| {
|
||||
let doc = parse_toml_document(&config_path(codex_dir), text)?;
|
||||
Ok::<_, CodexxError>(string_value(&doc, "model_provider").is_some())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
let live_auth = read_auth_value(&auth_path(codex_dir))?.filter(|auth| {
|
||||
is_chatgpt_auth(auth) || (has_explicit_official_route && has_openai_api_key(auth))
|
||||
});
|
||||
|
||||
let profile_id = selected_profile_id(codex_dir)?;
|
||||
let previous = match load_snapshot_for_profile(codex_dir, &profile_id)? {
|
||||
SnapshotState::Ready(candidate) | SnapshotState::Reset(candidate) => Some(candidate),
|
||||
SnapshotState::Missing => None,
|
||||
};
|
||||
let config = live_config.or_else(|| {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.config_text.clone())
|
||||
});
|
||||
let model = live_model.or_else(|| {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.model.clone())
|
||||
});
|
||||
let previous_auth = previous
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.auth.clone());
|
||||
let auth = if profile_id == DEFAULT_OFFICIAL_PROFILE_ID {
|
||||
prefer_chatgpt_auth(live_auth, previous_auth)
|
||||
} else {
|
||||
// A named profile is the user's selected working configuration. Its
|
||||
// current official login belongs to that profile, including an
|
||||
// intentional change between OAuth and API-key authentication.
|
||||
live_auth.or(previous_auth)
|
||||
};
|
||||
if config.is_none() && auth.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
write_official_profile_snapshot(codex_dir, &profile_id, config, model, auth)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
fn capture_live_official_auth(
|
||||
codex_dir: &Path,
|
||||
is_trusted: impl FnOnce(&Value) -> bool,
|
||||
@@ -349,15 +463,47 @@ fn capture_live_official_auth(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
|
||||
let path = official_snapshot_path(codex_dir)?;
|
||||
#[cfg(test)]
|
||||
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
fn load_snapshot_for_profile(codex_dir: &Path, profile_id: &str) -> Result<SnapshotState> {
|
||||
let path = official_snapshot_path_for_profile(codex_dir, profile_id)?;
|
||||
if !path.is_file() {
|
||||
return Ok(SnapshotState::Missing);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let snapshot: OfficialConfigSnapshot =
|
||||
serde_json::from_str(&text).map_err(|error| json_err(&path, error))?;
|
||||
snapshot_state(codex_dir, &path, snapshot)
|
||||
let snapshot: OfficialConfigSnapshot = match serde_json::from_str(&text) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(_) => return Ok(SnapshotState::Missing),
|
||||
};
|
||||
// This is an app-owned recovery cache, not the live source of truth. A
|
||||
// truncated, stale, or incompatible cache must never block startup or a
|
||||
// provider switch; valid live auth/history can repair it on the next write.
|
||||
Ok(snapshot_state(codex_dir, &path, snapshot).unwrap_or(SnapshotState::Missing))
|
||||
}
|
||||
|
||||
pub(crate) fn saved_official_profile_candidate(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
// Explicit account selection must never consult the live login or an
|
||||
// unrelated historical backup, even when the requested snapshot is empty.
|
||||
match load_snapshot_for_profile(codex_dir, profile_id)? {
|
||||
SnapshotState::Ready(candidate) | SnapshotState::Reset(candidate) => Ok(Some(candidate)),
|
||||
SnapshotState::Missing => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn prefer_chatgpt_auth(primary: Option<Value>, fallback: Option<Value>) -> Option<Value> {
|
||||
if primary.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return primary;
|
||||
}
|
||||
if fallback.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return fallback;
|
||||
}
|
||||
primary.or(fallback)
|
||||
}
|
||||
|
||||
fn snapshot_state(
|
||||
@@ -516,8 +662,11 @@ fn live_auth_candidate(
|
||||
}))
|
||||
}
|
||||
|
||||
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
live_auth_candidate(codex_dir, false, is_chatgpt_auth)
|
||||
fn live_chatgpt_candidate(
|
||||
codex_dir: &Path,
|
||||
allow_proxy_route: bool,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
live_auth_candidate(codex_dir, !allow_proxy_route, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
fn live_official_auth_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
@@ -549,27 +698,71 @@ fn complete_candidate_config(
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
fn selected_named_profile_candidate(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
let saved = saved_official_profile_candidate(codex_dir, profile_id)?;
|
||||
// The selected profile owns login/refreshes only while its official
|
||||
// route is active. OAuth left under a proxy route may belong to a
|
||||
// different account and must never replace an explicitly saved login.
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, saved.as_ref()).map(Some);
|
||||
}
|
||||
saved
|
||||
.map(|candidate| complete_candidate_config(codex_dir, candidate, None))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn official_config_candidate(
|
||||
codex_dir: &Path,
|
||||
include_history_after_reset: bool,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
match load_snapshot(codex_dir)? {
|
||||
let profile_id = selected_profile_id(codex_dir)?;
|
||||
if profile_id != DEFAULT_OFFICIAL_PROFILE_ID {
|
||||
return selected_named_profile_candidate(codex_dir, &profile_id);
|
||||
}
|
||||
// Legacy backups and OAuth left under proxy routes do not record which
|
||||
// account they belong to. Keep their old recovery behavior only while
|
||||
// there are no named accounts that they could be confused with.
|
||||
let allow_legacy_recovery = !has_named_profiles(codex_dir)?;
|
||||
match load_snapshot_for_profile(codex_dir, &profile_id)? {
|
||||
SnapshotState::Ready(candidate) => {
|
||||
// A third-party route may deliberately keep the user's ChatGPT
|
||||
// login in auth.json and carry its own key in
|
||||
// experimental_bearer_token. In legacy single-account mode,
|
||||
// preserve token refreshes made during proxy use. With named
|
||||
// profiles, only trust live OAuth under the official route.
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir, allow_legacy_recovery)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
|
||||
}
|
||||
// Never let an API-key-only live file silently downgrade a trusted
|
||||
// OAuth snapshot. Older Codex-X builds could leave exactly that
|
||||
// polluted state while config.toml already pointed at OpenAI.
|
||||
if candidate.auth.as_ref().is_some_and(is_chatgpt_auth) {
|
||||
return complete_candidate_config(codex_dir, candidate, None).map(Some);
|
||||
}
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&candidate)).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, candidate, None).map(Some);
|
||||
}
|
||||
SnapshotState::Reset(reset) if !include_history_after_reset => {
|
||||
SnapshotState::Reset(reset) if !include_history_after_reset || !allow_legacy_recovery => {
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir, allow_legacy_recovery)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
return complete_candidate_config(codex_dir, reset, None).map(Some);
|
||||
}
|
||||
SnapshotState::Reset(reset) => {
|
||||
if let Some(live) = live_official_auth_candidate(codex_dir)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(live) = live_chatgpt_candidate(codex_dir, allow_legacy_recovery)? {
|
||||
return complete_candidate_config(codex_dir, live, Some(&reset)).map(Some);
|
||||
}
|
||||
if let Some(mut backup) = latest_official_backup(codex_dir)? {
|
||||
if reset.config_text.is_some() {
|
||||
backup.config_text = reset.config_text;
|
||||
@@ -582,8 +775,12 @@ pub(crate) fn official_config_candidate(
|
||||
SnapshotState::Missing => {}
|
||||
}
|
||||
|
||||
let backup = latest_official_backup(codex_dir)?;
|
||||
if let Some(candidate) = live_chatgpt_candidate(codex_dir)? {
|
||||
let backup = if allow_legacy_recovery {
|
||||
latest_official_backup(codex_dir)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(candidate) = live_chatgpt_candidate(codex_dir, allow_legacy_recovery)? {
|
||||
return complete_candidate_config(codex_dir, candidate, backup.as_ref()).map(Some);
|
||||
}
|
||||
backup
|
||||
@@ -592,7 +789,8 @@ pub(crate) fn official_config_candidate(
|
||||
}
|
||||
|
||||
pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
|
||||
match load_snapshot(codex_dir)? {
|
||||
let profile_id = selected_profile_id(codex_dir)?;
|
||||
match load_snapshot_for_profile(codex_dir, &profile_id)? {
|
||||
SnapshotState::Ready(_) => return Ok(true),
|
||||
SnapshotState::Reset(_) => return Ok(live_official_auth_candidate(codex_dir)?.is_some()),
|
||||
SnapshotState::Missing => {}
|
||||
@@ -600,6 +798,9 @@ pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
|
||||
if live_official_auth_candidate(codex_dir)?.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
if profile_id != DEFAULT_OFFICIAL_PROFILE_ID || has_named_profiles(codex_dir)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(auth) = read_auth_value(&auth_path(codex_dir))? {
|
||||
if is_chatgpt_auth(&auth) {
|
||||
return Ok(true);
|
||||
@@ -610,6 +811,7 @@ pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn get_official_config_draft_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Option<OfficialConfigDraft>> {
|
||||
@@ -630,6 +832,7 @@ pub(crate) fn get_official_config_draft_inner(
|
||||
official_config_draft(candidate).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn official_config_draft(candidate: OfficialConfigCandidate) -> Result<OfficialConfigDraft> {
|
||||
let auth_json = candidate
|
||||
.auth
|
||||
@@ -656,6 +859,302 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn profile_test_dir(name: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-official-profile-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
fs::create_dir_all(&path).expect("create profile test Codex directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_snapshot_paths_keep_legacy_default_and_isolate_accounts_and_homes() {
|
||||
let codex_dir = profile_test_dir("paths");
|
||||
let other_home = profile_test_dir("other-home");
|
||||
let digest = Sha256::digest(canonical_identity(&codex_dir).as_bytes());
|
||||
let default_path =
|
||||
official_snapshot_path_for_profile(&codex_dir, DEFAULT_OFFICIAL_PROFILE_ID)
|
||||
.expect("default snapshot path");
|
||||
assert_eq!(
|
||||
default_path,
|
||||
app_home()
|
||||
.unwrap()
|
||||
.join("official-configs")
|
||||
.join(format!("{digest:x}.json"))
|
||||
);
|
||||
let first = official_snapshot_path_for_profile(&codex_dir, "account-a").unwrap();
|
||||
let second = official_snapshot_path_for_profile(&codex_dir, "account-b").unwrap();
|
||||
let elsewhere = official_snapshot_path_for_profile(&other_home, "account-a").unwrap();
|
||||
assert_ne!(first, default_path);
|
||||
assert_ne!(first, second);
|
||||
assert_ne!(first, elsewhere);
|
||||
assert_eq!(first.parent(), second.parent());
|
||||
assert!(official_snapshot_path_for_profile(&codex_dir, " ").is_err());
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
fs::remove_dir_all(other_home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_profile_snapshot_reads_never_borrow_other_auth_and_preserve_resets() {
|
||||
let codex_dir = profile_test_dir("explicit-read");
|
||||
let live_auth = json!({"tokens": {"access_token": "other-account"}});
|
||||
fs::write(
|
||||
config_path(&codex_dir),
|
||||
"model_provider = \"openai\"\nmodel = \"live-model\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
auth_path(&codex_dir),
|
||||
serde_json::to_vec(&live_auth).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
None,
|
||||
None,
|
||||
Some(live_auth),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
saved_official_profile_candidate(&codex_dir, "missing-account")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let profile_id = "saved-account";
|
||||
let saved_auth = json!({"tokens": {"access_token": "saved-account"}});
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
profile_id,
|
||||
None,
|
||||
Some("saved-model".to_string()),
|
||||
Some(saved_auth.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let candidate = saved_official_profile_candidate(&codex_dir, profile_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(candidate.auth, Some(saved_auth));
|
||||
assert!(candidate.config_text.is_none());
|
||||
assert_eq!(candidate.model.as_deref(), Some("saved-model"));
|
||||
|
||||
let path = official_snapshot_path_for_profile(&codex_dir, profile_id).unwrap();
|
||||
let before = fs::read(&path).unwrap();
|
||||
assert!(write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
profile_id,
|
||||
None,
|
||||
None,
|
||||
Some(json!({"auth_mode": "chatgpt"})),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), before);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
let reset_config = "model_provider = \"openai\"\nmodel = \"reset-model\"\n";
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
profile_id,
|
||||
Some(reset_config.to_string()),
|
||||
Some("reset-model".to_string()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reset = saved_official_profile_candidate(&codex_dir, profile_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(reset.auth.is_none());
|
||||
assert_eq!(reset.config_text.as_deref(), Some(reset_config));
|
||||
|
||||
fs::remove_file(path).unwrap();
|
||||
fs::remove_file(
|
||||
official_snapshot_path_for_profile(&codex_dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_named_profile_accepts_official_refresh_but_ignores_proxy_login() {
|
||||
let codex_dir = profile_test_dir("refresh");
|
||||
let profile_id = "selected-account";
|
||||
let saved_config = "model_provider = \"openai\"\nmodel = \"saved-model\"\n";
|
||||
let saved_auth = json!({"tokens": {"access_token": "saved-token"}});
|
||||
let refreshed_auth = json!({"tokens": {"access_token": "refreshed-token"}});
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
profile_id,
|
||||
Some(saved_config.to_string()),
|
||||
Some("saved-model".to_string()),
|
||||
Some(saved_auth.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
config_path(&codex_dir),
|
||||
"model_provider = \"custom\"\nmodel = \"proxy-model\"\n[model_providers.custom]\nname = \"Proxy\"\nbase_url = \"https://proxy.example/v1\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
auth_path(&codex_dir),
|
||||
serde_json::to_vec(&refreshed_auth).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let candidate = selected_named_profile_candidate(&codex_dir, profile_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(candidate.auth, Some(saved_auth));
|
||||
assert_eq!(candidate.config_text.as_deref(), Some(saved_config));
|
||||
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
profile_id,
|
||||
Some(saved_config.to_string()),
|
||||
Some("saved-model".to_string()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(selected_named_profile_candidate(&codex_dir, profile_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.auth
|
||||
.is_none());
|
||||
|
||||
let live_config = "model_provider = \"openai\"\nmodel = \"fresh-model\"\n";
|
||||
fs::write(config_path(&codex_dir), live_config).unwrap();
|
||||
let refreshed = selected_named_profile_candidate(&codex_dir, profile_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(refreshed.auth, Some(refreshed_auth));
|
||||
assert_eq!(refreshed.config_text.as_deref(), Some(live_config));
|
||||
let api_key_auth = json!({"OPENAI_API_KEY": "official-key"});
|
||||
fs::write(
|
||||
auth_path(&codex_dir),
|
||||
serde_json::to_vec(&api_key_auth).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected_named_profile_candidate(&codex_dir, profile_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.auth,
|
||||
Some(api_key_auth)
|
||||
);
|
||||
fs::remove_file(official_snapshot_path_for_profile(&codex_dir, profile_id).unwrap())
|
||||
.unwrap();
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_profile_ignores_proxy_oauth_once_named_profiles_exist() {
|
||||
let codex_dir = profile_test_dir("default-proxy-isolation");
|
||||
let default_auth = json!({"tokens": {"access_token": "default-account"}});
|
||||
let proxy_auth = json!({"tokens": {"access_token": "other-account"}});
|
||||
let official_config = "model_provider = \"openai\"\nmodel = \"official-model\"\n";
|
||||
fs::write(
|
||||
config_path(&codex_dir),
|
||||
"model_provider = \"custom\"\n[model_providers.custom]\nname = \"Proxy\"\nbase_url = \"https://proxy.example/v1\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
auth_path(&codex_dir),
|
||||
serde_json::to_vec(&proxy_auth).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
Some(official_config.to_string()),
|
||||
Some("official-model".to_string()),
|
||||
Some(default_auth.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Preserve existing single-account recovery of OAuth refreshed while
|
||||
// a proxy route is active.
|
||||
assert!(!has_named_profiles(&codex_dir).unwrap());
|
||||
assert_eq!(
|
||||
official_config_candidate(&codex_dir, false)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.auth,
|
||||
Some(proxy_auth.clone())
|
||||
);
|
||||
|
||||
let conn = crate::providers::open_store().unwrap();
|
||||
let scope = crate::paths::normalized_path_scope(&codex_dir);
|
||||
conn.execute(
|
||||
"INSERT INTO official_profiles (codex_dir, id, provider_name, created_at, updated_at)
|
||||
VALUES (?1, 'another-account', 'Another account', 'test', 'test')",
|
||||
[&scope],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_named_profiles(&codex_dir).unwrap());
|
||||
assert_eq!(
|
||||
selected_profile_id(&codex_dir).unwrap(),
|
||||
DEFAULT_OFFICIAL_PROFILE_ID
|
||||
);
|
||||
let saved = official_config_candidate(&codex_dir, false)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(saved.auth, Some(default_auth));
|
||||
assert_eq!(saved.config_text.as_deref(), Some(official_config));
|
||||
|
||||
write_official_profile_snapshot(
|
||||
&codex_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
Some(official_config.to_string()),
|
||||
Some("official-model".to_string()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
for include_history in [false, true] {
|
||||
assert!(official_config_candidate(&codex_dir, include_history)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.auth
|
||||
.is_none());
|
||||
}
|
||||
assert!(!official_auth_available(&codex_dir).unwrap());
|
||||
|
||||
let default_path =
|
||||
official_snapshot_path_for_profile(&codex_dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap();
|
||||
fs::remove_file(default_path).unwrap();
|
||||
for include_history in [false, true] {
|
||||
assert!(official_config_candidate(&codex_dir, include_history)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
assert!(!official_auth_available(&codex_dir).unwrap());
|
||||
|
||||
// Login and refreshes performed in the selected official profile
|
||||
// remain usable after multiple accounts have been added.
|
||||
fs::write(config_path(&codex_dir), official_config).unwrap();
|
||||
assert_eq!(
|
||||
official_config_candidate(&codex_dir, false)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.auth,
|
||||
Some(proxy_auth)
|
||||
);
|
||||
assert!(official_auth_available(&codex_dir).unwrap());
|
||||
conn.execute(
|
||||
"DELETE FROM official_profiles WHERE codex_dir = ?1",
|
||||
[&scope],
|
||||
)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_oauth_without_auth_mode_is_trusted() {
|
||||
assert!(is_chatgpt_auth(&json!({
|
||||
@@ -672,6 +1171,31 @@ mod tests {
|
||||
"tokens": {"access_token": "legacy-access"},
|
||||
"OPENAI_API_KEY": "sk-third-party"
|
||||
})));
|
||||
assert!(is_chatgpt_auth(&json!({
|
||||
"auth_mode": "chatgptAuthTokens",
|
||||
"tokens": {"access_token": "external-access"}
|
||||
})));
|
||||
assert!(is_chatgpt_auth(&json!({
|
||||
"auth_mode": "agentIdentity",
|
||||
"agent_identity": {
|
||||
"agent_runtime_id": "runtime-id",
|
||||
"agent_private_key": "private-key"
|
||||
}
|
||||
})));
|
||||
assert!(is_chatgpt_auth(&json!({
|
||||
"auth_mode": "personalAccessToken",
|
||||
"personal_access_token": "pat-test"
|
||||
})));
|
||||
assert!(!is_chatgpt_auth(&json!({
|
||||
"auth_mode": "bedrockApiKey",
|
||||
"bedrock_api_key": {"api_key": "bedrock-test", "region": "us-east-1"}
|
||||
})));
|
||||
assert!(!is_chatgpt_auth(&json!({
|
||||
"agent_identity": {
|
||||
"agent_runtime_id": "runtime-id",
|
||||
"agent_private_key": "private-key"
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
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
@@ -84,6 +84,7 @@ fn timestamp_expr(columns: &HashSet<String>, ms_column: &str, seconds_column: &s
|
||||
fn collect_catalog_repair_threads(
|
||||
paths: &[PathBuf],
|
||||
target_provider: &str,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
) -> Result<HashMap<String, CatalogRepairThread>> {
|
||||
let mut threads = HashMap::new();
|
||||
for path in paths {
|
||||
@@ -117,10 +118,15 @@ fn collect_catalog_repair_threads(
|
||||
let source_detail = text_expr(&columns, "rollout_path", "''");
|
||||
let git_branch = text_expr(&columns, "git_branch", "NULL");
|
||||
let thread_source = text_expr(&columns, "thread_source", "NULL");
|
||||
let archived_filter = if columns.contains("archived") {
|
||||
" AND COALESCE(archived, 0) = 0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id, {display_title}, {source_created_at}, {source_updated_at}, \
|
||||
{source_recency_at}, {cwd}, {source_kind}, {source_detail}, {git_branch}, \
|
||||
{thread_source} FROM threads WHERE COALESCE(id, '') <> ''"
|
||||
{thread_source} FROM threads WHERE COALESCE(id, '') <> ''{archived_filter}"
|
||||
);
|
||||
let mut statement = conn.prepare(&sql).map_err(database_error)?;
|
||||
let rows = statement
|
||||
@@ -144,6 +150,9 @@ fn collect_catalog_repair_threads(
|
||||
.map_err(database_error)?;
|
||||
for row in rows {
|
||||
let thread = row.map_err(database_error)?;
|
||||
if !syncable_thread_ids.contains(&thread.id) {
|
||||
continue;
|
||||
}
|
||||
let replace = threads
|
||||
.get(&thread.id)
|
||||
.map(|current: &CatalogRepairThread| {
|
||||
@@ -227,8 +236,10 @@ pub(super) fn scan_catalog_sync(
|
||||
thread_paths: &[PathBuf],
|
||||
catalog_paths: &[PathBuf],
|
||||
target_provider: &str,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogSyncScan> {
|
||||
let sources = collect_catalog_repair_threads(thread_paths, target_provider)?;
|
||||
let sources =
|
||||
collect_catalog_repair_threads(thread_paths, target_provider, syncable_thread_ids)?;
|
||||
let mut scan = CatalogSyncScan {
|
||||
sources,
|
||||
..CatalogSyncScan::default()
|
||||
@@ -255,8 +266,8 @@ pub(super) fn scan_catalog_sync(
|
||||
.map_err(database_error)?;
|
||||
for row in rows {
|
||||
let id = row.map_err(database_error)?;
|
||||
scan.mismatched_rows += 1;
|
||||
if !id.trim().is_empty() {
|
||||
if syncable_thread_ids.contains(&id) {
|
||||
scan.mismatched_rows += 1;
|
||||
scan.mismatched_thread_ids.insert(id);
|
||||
}
|
||||
}
|
||||
@@ -546,6 +557,7 @@ fn snapshot_catalog_provider_changes(
|
||||
conn: &Connection,
|
||||
columns: &HashSet<String>,
|
||||
target_provider: &str,
|
||||
provider_thread_ids: &HashSet<String>,
|
||||
) -> Result<()> {
|
||||
if !columns.contains("model_provider") || !columns.contains("thread_id") {
|
||||
return Ok(());
|
||||
@@ -564,10 +576,13 @@ fn snapshot_catalog_provider_changes(
|
||||
"INSERT INTO temp.codexx_catalog_provider_rollback \
|
||||
(host_id, thread_id, model_provider, missing_candidate) \
|
||||
SELECT {host_expr}, thread_id, model_provider, {missing_expr} \
|
||||
FROM local_thread_catalog WHERE COALESCE(model_provider, '') <> ?1"
|
||||
FROM local_thread_catalog WHERE COALESCE(model_provider, '') <> ?1 \
|
||||
AND thread_id = ?2"
|
||||
);
|
||||
conn.execute(&sql, [target_provider])
|
||||
.map_err(database_error)?;
|
||||
for thread_id in provider_thread_ids {
|
||||
conn.execute(&sql, (target_provider, thread_id))
|
||||
.map_err(database_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -663,6 +678,7 @@ pub(super) fn apply_catalog_updates(
|
||||
columns: &HashSet<String>,
|
||||
target_provider: &str,
|
||||
sources: &HashMap<String, CatalogRepairThread>,
|
||||
provider_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogUpdateCounts> {
|
||||
let mut counts = CatalogUpdateCounts::default();
|
||||
let repair_host = if !sources.is_empty() && catalog_supports_repair(columns) {
|
||||
@@ -670,17 +686,19 @@ pub(super) fn apply_catalog_updates(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider)?;
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider, provider_thread_ids)?;
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
snapshot_hidden_catalog_sources(conn, columns, host_id, sources)?;
|
||||
}
|
||||
if columns.contains("model_provider") && columns.contains("thread_id") {
|
||||
conn.execute(
|
||||
"UPDATE local_thread_catalog SET model_provider = ?1 \
|
||||
WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
for thread_id in provider_thread_ids {
|
||||
conn.execute(
|
||||
"UPDATE local_thread_catalog SET model_provider = ?1 \
|
||||
WHERE thread_id = ?2 AND COALESCE(model_provider, '') <> ?1",
|
||||
(target_provider, thread_id),
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
}
|
||||
}
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
reactivate_catalog_sources(conn, columns, host_id, sources)?;
|
||||
@@ -939,13 +957,20 @@ mod tests {
|
||||
conn: &Connection,
|
||||
target_provider: &str,
|
||||
sources: &HashMap<String, CatalogRepairThread>,
|
||||
provider_thread_ids: &HashSet<String>,
|
||||
) -> CatalogUpdateCounts {
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
.expect("begin catalog update");
|
||||
create_catalog_rollback_tables(conn).expect("create rollback tables");
|
||||
let columns = catalog_columns(conn).expect("read catalog columns");
|
||||
let counts = apply_catalog_updates(conn, &columns, target_provider, sources)
|
||||
.expect("apply catalog update");
|
||||
let counts = apply_catalog_updates(
|
||||
conn,
|
||||
&columns,
|
||||
target_provider,
|
||||
sources,
|
||||
provider_thread_ids,
|
||||
)
|
||||
.expect("apply catalog update");
|
||||
conn.execute_batch("COMMIT").expect("commit catalog update");
|
||||
counts
|
||||
}
|
||||
@@ -983,10 +1008,12 @@ mod tests {
|
||||
insert_catalog_row(&catalog, "thread-hidden", "custom", 1);
|
||||
drop(catalog);
|
||||
|
||||
let syncable_thread_ids = HashSet::from(["thread-hidden".to_string()]);
|
||||
let scan = scan_catalog_sync(
|
||||
&[thread_path],
|
||||
std::slice::from_ref(&catalog_path),
|
||||
"custom",
|
||||
&syncable_thread_ids,
|
||||
)
|
||||
.expect("scan hidden catalog row");
|
||||
assert_eq!(scan.mismatched_rows, 0);
|
||||
@@ -994,7 +1021,7 @@ mod tests {
|
||||
assert!(scan.mismatched_thread_ids.contains("thread-hidden"));
|
||||
|
||||
let catalog = Connection::open(&catalog_path).expect("open catalog for update");
|
||||
let counts = apply_and_commit(&catalog, "custom", &scan.sources);
|
||||
let counts = apply_and_commit(&catalog, "custom", &scan.sources, &syncable_thread_ids);
|
||||
assert_eq!(counts.provider_rows, 1);
|
||||
assert_eq!(counts.inserted_rows, 0);
|
||||
assert_eq!(
|
||||
@@ -1016,8 +1043,9 @@ mod tests {
|
||||
let conn = Connection::open_in_memory().expect("open in-memory catalog");
|
||||
create_catalog_schema(&conn);
|
||||
insert_catalog_row(&conn, "thread-provider", "openai", 0);
|
||||
let provider_thread_ids = HashSet::from(["thread-provider".to_string()]);
|
||||
|
||||
let counts = apply_and_commit(&conn, "custom", &HashMap::new());
|
||||
let counts = apply_and_commit(&conn, "custom", &HashMap::new(), &provider_thread_ids);
|
||||
assert_eq!(counts.provider_rows, 1);
|
||||
assert_eq!(counts.inserted_rows, 0);
|
||||
assert_eq!(
|
||||
@@ -1070,8 +1098,9 @@ mod tests {
|
||||
"thread-combined".to_string(),
|
||||
repair_source("thread-combined", "custom"),
|
||||
)]);
|
||||
let provider_thread_ids = HashSet::from(["thread-combined".to_string()]);
|
||||
|
||||
let counts = apply_and_commit(&conn, "custom", &sources);
|
||||
let counts = apply_and_commit(&conn, "custom", &sources, &provider_thread_ids);
|
||||
assert_eq!(counts.provider_rows, 1);
|
||||
assert_eq!(counts.inserted_rows, 0);
|
||||
assert_eq!(
|
||||
|
||||
@@ -314,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()))?;
|
||||
@@ -324,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,
|
||||
@@ -338,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);
|
||||
|
||||
@@ -15,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};
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
pub(super) fn current_model_provider(codex_dir: &Path, explicit: Option<String>) -> Result<String> {
|
||||
@@ -62,6 +62,7 @@ pub(super) fn is_canonical_rollout_storage_path(codex_dir: &Path, path: &Path) -
|
||||
fn referenced_rollout_paths(
|
||||
codex_dir: &Path,
|
||||
rollout_paths_by_thread_id: &HashMap<String, String>,
|
||||
include_archived_storage: bool,
|
||||
failures: &mut Vec<String>,
|
||||
) -> HashMap<PathBuf, HashSet<String>> {
|
||||
let mut referenced = HashMap::<PathBuf, HashSet<String>>::new();
|
||||
@@ -106,11 +107,23 @@ fn referenced_rollout_paths(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !is_canonical_rollout_storage_path(codex_dir, &canonical) {
|
||||
failures.push(format!(
|
||||
"活动 SQLite 引用的会话文件超出 Codex 会话目录: {}",
|
||||
path.display()
|
||||
));
|
||||
let within_codex_storage = is_canonical_rollout_storage_path(codex_dir, &canonical);
|
||||
let allowed_root = if include_archived_storage {
|
||||
within_codex_storage
|
||||
} else {
|
||||
within_codex_storage
|
||||
&& codex_dir
|
||||
.join("sessions")
|
||||
.canonicalize()
|
||||
.is_ok_and(|root| canonical.starts_with(root))
|
||||
};
|
||||
if !allowed_root {
|
||||
let reason = if within_codex_storage {
|
||||
"活动 SQLite 引用了归档会话文件"
|
||||
} else {
|
||||
"活动 SQLite 引用的会话文件超出 Codex 会话目录"
|
||||
};
|
||||
failures.push(format!("{reason}: {}", path.display()));
|
||||
continue;
|
||||
}
|
||||
let expected_thread_ids = referenced.entry(canonical.clone()).or_default();
|
||||
@@ -181,7 +194,23 @@ fn is_locked_io_error(error: &std::io::Error) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<RolloutScan> {
|
||||
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None)
|
||||
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None, true, None, false)
|
||||
}
|
||||
|
||||
pub(super) fn scan_provider_rollouts(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
excluded_thread_ids: &HashSet<String>,
|
||||
) -> Result<RolloutScan> {
|
||||
scan_rollouts_with_thread_filter(
|
||||
codex_dir,
|
||||
target_provider,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
Some(excluded_thread_ids),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn scan_rollouts_for_thread_ids(
|
||||
@@ -195,6 +224,9 @@ pub(super) fn scan_rollouts_for_thread_ids(
|
||||
target_provider,
|
||||
Some(thread_ids),
|
||||
Some(rollout_paths_by_thread_id),
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,15 +235,34 @@ fn scan_rollouts_with_thread_filter(
|
||||
target_provider: &str,
|
||||
allowed_thread_ids: Option<&HashSet<String>>,
|
||||
rollout_paths_by_thread_id: Option<&HashMap<String, String>>,
|
||||
include_archived_storage: bool,
|
||||
excluded_thread_ids: Option<&HashSet<String>>,
|
||||
exclude_source_marked_subagents: bool,
|
||||
) -> Result<RolloutScan> {
|
||||
let mut paths = Vec::new();
|
||||
let mut scan = RolloutScan::default();
|
||||
let mut referenced = HashMap::new();
|
||||
for dir in ["sessions", "archived_sessions"] {
|
||||
collect_rollout_paths(&codex_dir.join(dir), &mut paths, &mut scan.scan_failures);
|
||||
collect_rollout_paths(
|
||||
&codex_dir.join("sessions"),
|
||||
&mut paths,
|
||||
&mut scan.scan_failures,
|
||||
);
|
||||
if include_archived_storage {
|
||||
collect_rollout_paths(
|
||||
&codex_dir.join("archived_sessions"),
|
||||
&mut paths,
|
||||
&mut scan.scan_failures,
|
||||
);
|
||||
}
|
||||
paths.sort();
|
||||
scan.discovered_rollout_files = paths.len();
|
||||
if let Some(excluded_thread_ids) = excluded_thread_ids {
|
||||
paths.retain(|path| {
|
||||
!excluded_thread_ids
|
||||
.iter()
|
||||
.any(|id| rollout_filename_matches_id(path, id))
|
||||
});
|
||||
}
|
||||
if let Some(allowed_thread_ids) = allowed_thread_ids {
|
||||
let empty_rollout_paths = HashMap::new();
|
||||
let rollout_paths_by_thread_id = rollout_paths_by_thread_id.unwrap_or(&empty_rollout_paths);
|
||||
@@ -223,6 +274,7 @@ fn scan_rollouts_with_thread_filter(
|
||||
referenced = referenced_rollout_paths(
|
||||
codex_dir,
|
||||
rollout_paths_by_thread_id,
|
||||
include_archived_storage,
|
||||
&mut scan.scan_failures,
|
||||
);
|
||||
paths.retain(|path| {
|
||||
@@ -264,6 +316,7 @@ fn scan_rollouts_with_thread_filter(
|
||||
let mut invalid_session_meta = 0usize;
|
||||
let mut thread_id = None;
|
||||
let mut cwd = None;
|
||||
let mut is_subagent = false;
|
||||
|
||||
for segment in text.split_inclusive('\n') {
|
||||
let (line, line_ending) = split_line_ending(segment);
|
||||
@@ -287,6 +340,9 @@ fn scan_rollouts_with_thread_filter(
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_workspace_path);
|
||||
}
|
||||
if payload.get("source").is_some_and(source_value_is_subagent) {
|
||||
is_subagent = true;
|
||||
}
|
||||
if payload.get("model_provider").and_then(Value::as_str)
|
||||
!= Some(target_provider)
|
||||
{
|
||||
@@ -311,6 +367,13 @@ fn scan_rollouts_with_thread_filter(
|
||||
next_text.push_str(line_ending);
|
||||
}
|
||||
|
||||
if (exclude_source_marked_subagents && is_subagent)
|
||||
|| thread_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| excluded_thread_ids.is_some_and(|excluded| excluded.contains(id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if invalid_json_lines > 0 {
|
||||
scan.scan_failures.push(format!(
|
||||
"会话文件包含 {invalid_json_lines} 行无法解析的 JSON: {}",
|
||||
@@ -353,6 +416,7 @@ fn scan_rollouts_with_thread_filter(
|
||||
continue;
|
||||
}
|
||||
scan.session_meta_count += file_session_meta_count;
|
||||
scan.thread_ids.insert(thread_id.clone());
|
||||
if let Some(cwd) = cwd {
|
||||
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
|
||||
}
|
||||
@@ -636,12 +700,15 @@ fn sqlite_state_version(path: &Path) -> Option<u64> {
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
fn sqlite_table_names(path: &Path) -> Option<HashSet<String>> {
|
||||
fn sqlite_table_names(path: &Path, busy_timeout: Option<Duration>) -> Option<HashSet<String>> {
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.ok()?;
|
||||
if let Some(timeout) = busy_timeout {
|
||||
conn.busy_timeout(timeout).ok()?;
|
||||
}
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.ok()?;
|
||||
@@ -714,6 +781,13 @@ fn ordered_database_paths(
|
||||
}
|
||||
|
||||
pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|
||||
discover_sqlite_databases_with_busy_timeout(codex_dir, None)
|
||||
}
|
||||
|
||||
fn discover_sqlite_databases_with_busy_timeout(
|
||||
codex_dir: &Path,
|
||||
busy_timeout: Option<Duration>,
|
||||
) -> SqliteDiscovery {
|
||||
let mut databases = Vec::new();
|
||||
let mut seen_paths = HashSet::new();
|
||||
let mut unreadable_paths = Vec::new();
|
||||
@@ -774,7 +848,7 @@ pub(super) fn discover_sqlite_databases(codex_dir: &Path) -> SqliteDiscovery {
|
||||
continue;
|
||||
}
|
||||
let codex_named = is_root_codex_sqlite_file(&path);
|
||||
let Some(tables) = sqlite_table_names(&path) else {
|
||||
let Some(tables) = sqlite_table_names(&path, busy_timeout) else {
|
||||
let header_is_sqlite = has_sqlite_header(&path);
|
||||
let read_error = fs::File::open(&path).err();
|
||||
if header_is_sqlite || read_error.is_some() {
|
||||
@@ -848,6 +922,116 @@ pub(crate) fn sqlite_candidate_paths(codex_dir: &Path) -> Vec<PathBuf> {
|
||||
discover_sqlite_databases(codex_dir).active_paths
|
||||
}
|
||||
|
||||
fn clean_session_title(values: [Option<String>; 3]) -> Option<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|value| value.trim().to_string())
|
||||
.find(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn session_project_title(cwd: &str) -> Option<String> {
|
||||
let path = normalize_workspace_path(cwd)?.replace('\\', "/");
|
||||
if path.chars().any(char::is_control) || path.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let parts = path
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
// A UNC share root is a storage location, rather than a project directory.
|
||||
if path.starts_with("//") && parts.len() <= 2 {
|
||||
return None;
|
||||
}
|
||||
let name = parts.last()?.trim();
|
||||
if name.is_empty()
|
||||
|| matches!(name, "." | ".." | "~")
|
||||
|| (name.len() == 2 && name.as_bytes()[0].is_ascii_alphabetic() && name.ends_with(':'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
/// Best-effort titles for the small recent-usage list. Read only the requested
|
||||
/// IDs and re-read metadata on each refresh so renamed chats appear immediately.
|
||||
pub(crate) fn session_titles_by_id(codex_dir: &Path, ids: &[String]) -> HashMap<String, String> {
|
||||
let ids = ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
if ids.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
let timeout = Duration::from_millis(50);
|
||||
let discovery = discover_sqlite_databases_with_busy_timeout(codex_dir, Some(timeout));
|
||||
let mut titles = HashMap::new();
|
||||
let mut projects = HashMap::new();
|
||||
for path in discovery.active_first_session_paths() {
|
||||
let Ok(conn) = Connection::open_with_flags(
|
||||
&path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if conn.busy_timeout(timeout).is_err()
|
||||
|| !sqlite_has_table(&conn, "threads").unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(cols) = table_column_set(&conn, "threads") else {
|
||||
continue;
|
||||
};
|
||||
if !cols.contains("id") {
|
||||
continue;
|
||||
}
|
||||
let unresolved = ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !titles.contains_key(*id))
|
||||
.collect::<Vec<_>>();
|
||||
if unresolved.is_empty() {
|
||||
break;
|
||||
}
|
||||
let title = sql_select_column(&cols, "title", "NULL");
|
||||
let first = sql_select_column(&cols, "first_user_message", "NULL");
|
||||
let preview = sql_select_column(&cols, "preview", "NULL");
|
||||
let cwd = sql_select_column(&cols, "cwd", "NULL");
|
||||
// Keep below SQLite's variable limit even if another caller requests more IDs.
|
||||
for chunk in unresolved.chunks(400) {
|
||||
let placeholders = vec!["?"; chunk.len()].join(",");
|
||||
let query = format!("SELECT \"id\", {title}, {first}, {preview}, {cwd} FROM threads WHERE \"id\" IN ({placeholders})");
|
||||
let Ok(mut statement) = conn.prepare(&query) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(rows) = statement.query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
|
||||
let id = row.get::<_, String>(0)?;
|
||||
let text = |index| row.get::<_, Option<String>>(index).ok().flatten();
|
||||
Ok((
|
||||
id,
|
||||
clean_session_title([text(1), text(2), text(3)]),
|
||||
text(4),
|
||||
))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
for (id, title, cwd) in rows.flatten() {
|
||||
if let Some(title) = title {
|
||||
titles.entry(id).or_insert(title);
|
||||
} else if let Some(project) = cwd.as_deref().and_then(session_project_title) {
|
||||
projects.entry(id).or_insert(project);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prefer a real chat title from an older database over a project-only fallback.
|
||||
for (id, project) in projects {
|
||||
titles.entry(id).or_insert(project);
|
||||
}
|
||||
titles
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn sqlite_session_db_paths(codex_dir: &Path) -> Vec<PathBuf> {
|
||||
discover_sqlite_databases(codex_dir).session_paths
|
||||
@@ -915,15 +1099,7 @@ pub(super) fn sqlite_subagent_thread_ids(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let source_is_subagent = source.eq_ignore_ascii_case("subagent")
|
||||
|| serde_json::from_str::<Value>(source)
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
value
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("subagent"))
|
||||
});
|
||||
if source_is_subagent {
|
||||
if source_text_is_subagent(source) {
|
||||
ids.insert(id);
|
||||
} else {
|
||||
ids.remove(&id);
|
||||
@@ -935,11 +1111,183 @@ pub(super) fn sqlite_subagent_thread_ids(
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub(crate) fn source_value_is_subagent(source: &Value) -> bool {
|
||||
match source {
|
||||
Value::String(source) => source.trim().eq_ignore_ascii_case("subagent"),
|
||||
Value::Object(source) => source.contains_key("subagent"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_text_is_subagent(source: &str) -> bool {
|
||||
let source = source.trim();
|
||||
source.eq_ignore_ascii_case("subagent")
|
||||
|| serde_json::from_str::<Value>(source)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.is_some_and(source_value_is_subagent)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct UsageThreadIdentity {
|
||||
pub(crate) is_subagent: bool,
|
||||
pub(crate) classification_known: bool,
|
||||
pub(crate) parent_id: Option<String>,
|
||||
pub(crate) parent_conflict: bool,
|
||||
}
|
||||
|
||||
fn usage_identities_on_connection(
|
||||
conn: &Connection,
|
||||
) -> Result<HashMap<String, (bool, bool, HashSet<String>)>> {
|
||||
if !sqlite_has_table(conn, "threads")? {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let cols = table_column_set(conn, "threads")?;
|
||||
if !cols.contains("id") {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let subagents = sqlite_subagent_thread_ids(conn, &cols)?;
|
||||
let source = sql_select_column(&cols, "source", "NULL");
|
||||
let thread_source = sql_select_column(&cols, "thread_source", "NULL");
|
||||
let mut statement = conn
|
||||
.prepare(&format!(
|
||||
"SELECT id, {source}, {thread_source} FROM threads"
|
||||
))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1).ok().flatten(),
|
||||
row.get::<_, Option<String>>(2).ok().flatten(),
|
||||
))
|
||||
})
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let mut identities = HashMap::new();
|
||||
for row in rows {
|
||||
let (id, source, thread_source) =
|
||||
row.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_subagent = subagents.contains(&id);
|
||||
let classification_known = is_subagent
|
||||
|| thread_source
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
|| source
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
let mut parents = HashSet::new();
|
||||
if is_subagent {
|
||||
if let Some(parent) = source
|
||||
.as_deref()
|
||||
.and_then(|text| serde_json::from_str::<Value>(text).ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.pointer("/subagent/thread_spawn/parent_thread_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
parents.insert(parent);
|
||||
}
|
||||
}
|
||||
identities.insert(id, (is_subagent, classification_known, parents));
|
||||
}
|
||||
if sqlite_has_table(conn, "thread_spawn_edges")? {
|
||||
let cols = table_column_set(conn, "thread_spawn_edges")?;
|
||||
if cols.contains("child_thread_id") && cols.contains("parent_thread_id") {
|
||||
let mut statement = conn
|
||||
.prepare("SELECT child_thread_id, parent_thread_id FROM thread_spawn_edges")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1).ok().flatten(),
|
||||
))
|
||||
})
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
for row in rows {
|
||||
let (child, parent) =
|
||||
row.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if let Some((true, _, parents)) = identities.get_mut(&child) {
|
||||
if let Some(parent) = parent
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
parents.insert(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
/// Reuse session-management classification without reading any rollout content.
|
||||
/// Current database records override stale legacy classification and parentage.
|
||||
pub(crate) fn usage_thread_identities(codex_dir: &Path) -> HashMap<String, UsageThreadIdentity> {
|
||||
let timeout = Duration::from_millis(50);
|
||||
let discovery = discover_sqlite_databases_with_busy_timeout(codex_dir, Some(timeout));
|
||||
let mut active_ids = HashSet::new();
|
||||
let mut combined = HashMap::<String, (bool, bool, HashSet<String>)>::new();
|
||||
for path in discovery.active_first_session_paths() {
|
||||
let Ok(conn) = Connection::open_with_flags(
|
||||
&path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if conn.busy_timeout(timeout).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(identities) = usage_identities_on_connection(&conn) else {
|
||||
continue;
|
||||
};
|
||||
let is_active = discovery.active_paths.contains(&path);
|
||||
for (id, (is_subagent, classification_known, parents)) in identities {
|
||||
if is_active {
|
||||
active_ids.insert(id.clone());
|
||||
combined.insert(id, (is_subagent, classification_known, parents));
|
||||
} else if !active_ids.contains(&id) {
|
||||
let existing = combined
|
||||
.entry(id)
|
||||
.or_insert_with(|| (false, false, HashSet::new()));
|
||||
existing.0 |= is_subagent;
|
||||
existing.1 |= classification_known;
|
||||
existing.2.extend(parents);
|
||||
}
|
||||
}
|
||||
}
|
||||
combined
|
||||
.into_iter()
|
||||
.map(|(id, (is_subagent, classification_known, parents))| {
|
||||
let parent_conflict = is_subagent && parents.len() > 1;
|
||||
let parent_id =
|
||||
(is_subagent && parents.len() == 1).then(|| parents.into_iter().next().unwrap());
|
||||
(
|
||||
id,
|
||||
UsageThreadIdentity {
|
||||
is_subagent,
|
||||
classification_known,
|
||||
parent_id,
|
||||
parent_conflict,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) struct SqliteThreadIndexState<'a> {
|
||||
pub(super) thread_id: &'a str,
|
||||
pub(super) provider: Option<&'a str>,
|
||||
pub(super) cwd: Option<&'a str>,
|
||||
pub(super) cwd_column: bool,
|
||||
pub(super) archived: bool,
|
||||
}
|
||||
|
||||
pub(super) fn sqlite_thread_needs_alignment(
|
||||
@@ -947,6 +1295,9 @@ pub(super) fn sqlite_thread_needs_alignment(
|
||||
target_provider: &str,
|
||||
state: &SqliteThreadIndexState<'_>,
|
||||
) -> bool {
|
||||
if state.archived {
|
||||
return false;
|
||||
}
|
||||
if state.provider.map(str::trim).unwrap_or_default() != target_provider {
|
||||
return true;
|
||||
}
|
||||
@@ -979,6 +1330,8 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
) -> Result<SqliteScan> {
|
||||
let mut scan = SqliteScan::default();
|
||||
let mut thread_ids = HashSet::new();
|
||||
let mut syncable_thread_ids = HashSet::new();
|
||||
let mut archived_thread_ids = HashSet::new();
|
||||
let mut rollout_paths_by_thread_id = HashMap::new();
|
||||
let mut subagent_ids = HashSet::new();
|
||||
let mut mismatched_ids = HashSet::new();
|
||||
@@ -1008,8 +1361,10 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
scan.sqlite_dbs += 1;
|
||||
let cwd_col = sql_select_column(&cols, "cwd", "NULL");
|
||||
let rollout_col = sql_select_column(&cols, "rollout_path", "NULL");
|
||||
let query =
|
||||
format!("SELECT \"id\", \"model_provider\", {cwd_col}, {rollout_col} FROM threads");
|
||||
let archived_col = sql_select_column(&cols, "archived", "0");
|
||||
let query = format!(
|
||||
"SELECT \"id\", \"model_provider\", {cwd_col}, {rollout_col}, {archived_col} FROM threads"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(&query)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -1020,18 +1375,25 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, i64>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
for row in rows {
|
||||
let (id, provider, cwd, rollout_path) =
|
||||
let (id, provider, cwd, rollout_path, archived) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
thread_ids.insert(id.clone());
|
||||
if let Some(rollout_path) = rollout_path
|
||||
.map(|path| path.trim().to_string())
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
rollout_paths_by_thread_id.insert(id.clone(), rollout_path);
|
||||
let archived = archived != 0;
|
||||
if archived {
|
||||
archived_thread_ids.insert(id.clone());
|
||||
} else {
|
||||
syncable_thread_ids.insert(id.clone());
|
||||
if let Some(rollout_path) = rollout_path
|
||||
.map(|path| path.trim().to_string())
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
rollout_paths_by_thread_id.insert(id.clone(), rollout_path);
|
||||
}
|
||||
}
|
||||
if sqlite_thread_needs_alignment(
|
||||
rollouts,
|
||||
@@ -1041,6 +1403,7 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
provider: provider.as_deref(),
|
||||
cwd: cwd.as_deref(),
|
||||
cwd_column: cols.contains("cwd"),
|
||||
archived,
|
||||
},
|
||||
) {
|
||||
mismatched_ids.insert(id);
|
||||
@@ -1049,11 +1412,17 @@ pub(super) fn scan_sqlite_with_paths(
|
||||
subagent_ids.extend(sqlite_subagent_thread_ids(&conn, &cols)?);
|
||||
}
|
||||
subagent_ids.retain(|id| thread_ids.contains(id));
|
||||
syncable_thread_ids.retain(|id| !subagent_ids.contains(id));
|
||||
rollout_paths_by_thread_id.retain(|id, _| !subagent_ids.contains(id));
|
||||
mismatched_ids.retain(|id| !subagent_ids.contains(id));
|
||||
scan.sqlite_threads = thread_ids.len();
|
||||
scan.subagent_threads = subagent_ids.len();
|
||||
scan.top_level_threads = thread_ids.len().saturating_sub(subagent_ids.len());
|
||||
scan.mismatched_threads = mismatched_ids.len();
|
||||
scan.thread_ids = thread_ids;
|
||||
scan.syncable_thread_ids = syncable_thread_ids;
|
||||
scan.archived_thread_ids = archived_thread_ids;
|
||||
scan.subagent_thread_ids = subagent_ids;
|
||||
scan.rollout_paths_by_thread_id = rollout_paths_by_thread_id;
|
||||
scan.mismatched_thread_ids = mismatched_ids;
|
||||
Ok(scan)
|
||||
@@ -1145,11 +1514,7 @@ pub(super) fn list_session_previews_with_paths(
|
||||
let updated_at: Option<i64> = row.get(9)?;
|
||||
let archived: i64 = row.get(10)?;
|
||||
let has_user_event: i64 = row.get(11)?;
|
||||
let clean_title = [title, first_message, preview]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|v| v.trim().to_string())
|
||||
.find(|v| !v.is_empty())
|
||||
let clean_title = clean_session_title([title, first_message, preview])
|
||||
.unwrap_or_else(|| format!("会话 {}", id.chars().take(8).collect::<String>()));
|
||||
let normalized_provider = model_provider
|
||||
.as_ref()
|
||||
@@ -1160,18 +1525,22 @@ pub(super) fn list_session_previews_with_paths(
|
||||
.as_ref()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
let needs_sync = rollouts.mismatched_thread_ids.contains(&id)
|
||||
|| sqlite_thread_needs_alignment(
|
||||
rollouts,
|
||||
target_provider,
|
||||
&SqliteThreadIndexState {
|
||||
thread_id: &id,
|
||||
provider: normalized_provider.as_deref(),
|
||||
cwd: normalized_cwd.as_deref(),
|
||||
cwd_column: cols.contains("cwd"),
|
||||
},
|
||||
);
|
||||
let is_archived = archived != 0;
|
||||
let is_subagent = subagent_thread_ids.contains(&id);
|
||||
let needs_sync = !is_archived
|
||||
&& !is_subagent
|
||||
&& (rollouts.mismatched_thread_ids.contains(&id)
|
||||
|| sqlite_thread_needs_alignment(
|
||||
rollouts,
|
||||
target_provider,
|
||||
&SqliteThreadIndexState {
|
||||
thread_id: &id,
|
||||
provider: normalized_provider.as_deref(),
|
||||
cwd: normalized_cwd.as_deref(),
|
||||
cwd_column: cols.contains("cwd"),
|
||||
archived: is_archived,
|
||||
},
|
||||
));
|
||||
Ok(SessionPreview {
|
||||
id,
|
||||
title: clean_title,
|
||||
@@ -1183,7 +1552,7 @@ pub(super) fn list_session_previews_with_paths(
|
||||
cwd: normalized_cwd,
|
||||
rollout_path: normalized_rollout_path,
|
||||
updated_at_ms: updated_at_ms.or_else(|| updated_at.map(|v| v * 1000)),
|
||||
archived: archived != 0,
|
||||
archived: is_archived,
|
||||
has_user_event: has_user_event != 0,
|
||||
is_subagent,
|
||||
needs_sync,
|
||||
@@ -1252,6 +1621,335 @@ mod tests {
|
||||
.expect("insert thread");
|
||||
}
|
||||
|
||||
fn create_title_database(path: &Path) -> Connection {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title, first_user_message, preview, cwd TEXT);").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn create_identity_database(path: &Path) -> Connection {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE threads (id TEXT PRIMARY KEY, thread_source TEXT, source TEXT);
|
||||
CREATE TABLE thread_spawn_edges (parent_thread_id TEXT, child_thread_id TEXT);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_thread_identity_reuses_edges_source_and_authoritative_thread_source() {
|
||||
let dir = temp_codex_dir("usage-identities-source");
|
||||
let path = dir.join("state_10.sqlite");
|
||||
let conn = create_identity_database(&path);
|
||||
conn.execute_batch(r#"INSERT INTO threads VALUES
|
||||
('parent', 'user', NULL),
|
||||
('edge', NULL, NULL),
|
||||
('source', NULL, '{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}'),
|
||||
('thread-source', 'subagent', NULL),
|
||||
('source-only', NULL, 'subagent'),
|
||||
('user-override', 'user', '{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}');
|
||||
INSERT INTO thread_spawn_edges VALUES ('parent', 'edge'), ('parent', 'user-override');"#).unwrap();
|
||||
drop(conn);
|
||||
let before = fs::read(&path).unwrap();
|
||||
let identities = usage_thread_identities(&dir);
|
||||
assert_eq!(
|
||||
identities["edge"],
|
||||
UsageThreadIdentity {
|
||||
classification_known: true,
|
||||
is_subagent: true,
|
||||
parent_id: Some("parent".into()),
|
||||
parent_conflict: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(identities["source"], identities["edge"]);
|
||||
assert_eq!(
|
||||
identities["thread-source"],
|
||||
UsageThreadIdentity {
|
||||
classification_known: true,
|
||||
is_subagent: true,
|
||||
parent_id: None,
|
||||
parent_conflict: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(identities["source-only"], identities["thread-source"]);
|
||||
assert_eq!(
|
||||
identities["user-override"],
|
||||
UsageThreadIdentity {
|
||||
classification_known: true,
|
||||
is_subagent: false,
|
||||
parent_id: None,
|
||||
parent_conflict: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(identities["parent"], identities["user-override"]);
|
||||
assert_eq!(fs::read(&path).unwrap(), before);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_thread_identity_missing_or_blank_source_is_unknown_not_explicit_user() {
|
||||
let dir = temp_codex_dir("usage-identities-unknown");
|
||||
let active = create_identity_database(&dir.join("state_10.sqlite"));
|
||||
active
|
||||
.execute_batch(
|
||||
"INSERT INTO threads VALUES
|
||||
('null-source', NULL, NULL), ('blank-source', ' ', ' '),
|
||||
('known-user', 'user', NULL), ('source-user', NULL, 'cli');",
|
||||
)
|
||||
.unwrap();
|
||||
let legacy_path = dir.join("sqlite/state_5.sqlite");
|
||||
create_thread_database(&legacy_path, "no-source-columns", "openai");
|
||||
let identities = usage_thread_identities(&dir);
|
||||
for id in ["null-source", "blank-source", "no-source-columns"] {
|
||||
assert_eq!(
|
||||
identities[id],
|
||||
UsageThreadIdentity {
|
||||
is_subagent: false,
|
||||
classification_known: false,
|
||||
parent_id: None,
|
||||
parent_conflict: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
for id in ["known-user", "source-user"] {
|
||||
assert!(!identities[id].is_subagent);
|
||||
assert!(identities[id].classification_known);
|
||||
}
|
||||
drop(active);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_thread_identity_active_records_override_stale_legacy_subagents_and_parents() {
|
||||
let dir = temp_codex_dir("usage-identities-active");
|
||||
let active = create_identity_database(&dir.join("state_10.sqlite"));
|
||||
let legacy = create_identity_database(&dir.join("sqlite/state_5.sqlite"));
|
||||
active
|
||||
.execute_batch(
|
||||
"INSERT INTO threads VALUES ('user', 'user', NULL), ('child', 'subagent', NULL);
|
||||
INSERT INTO thread_spawn_edges VALUES ('current-parent', 'child');",
|
||||
)
|
||||
.unwrap();
|
||||
legacy.execute_batch("INSERT INTO threads VALUES ('user', 'subagent', NULL), ('child', 'subagent', NULL), ('legacy-child', 'subagent', NULL);
|
||||
INSERT INTO thread_spawn_edges VALUES ('old-parent', 'user'), ('old-parent', 'child'), ('legacy-parent', 'legacy-child');").unwrap();
|
||||
let identities = usage_thread_identities(&dir);
|
||||
assert_eq!(
|
||||
identities["user"],
|
||||
UsageThreadIdentity {
|
||||
classification_known: true,
|
||||
is_subagent: false,
|
||||
parent_id: None,
|
||||
parent_conflict: false,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
identities["child"].parent_id.as_deref(),
|
||||
Some("current-parent")
|
||||
);
|
||||
assert_eq!(
|
||||
identities["legacy-child"].parent_id.as_deref(),
|
||||
Some("legacy-parent")
|
||||
);
|
||||
drop(active);
|
||||
drop(legacy);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_thread_identity_conflicting_parent_edges_or_sources_remain_unassigned() {
|
||||
let dir = temp_codex_dir("usage-identities-conflicts");
|
||||
let active = create_identity_database(&dir.join("state_10.sqlite"));
|
||||
let first = create_identity_database(&dir.join("sqlite/state_5.sqlite"));
|
||||
let second = create_identity_database(&dir.join("sqlite/state_6.sqlite"));
|
||||
active.execute_batch(r#"INSERT INTO threads VALUES ('conflict', 'subagent', '{"subagent":{"thread_spawn":{"parent_thread_id":"source-parent"}}}');
|
||||
INSERT INTO thread_spawn_edges VALUES ('edge-parent', 'conflict');"#).unwrap();
|
||||
first.execute_batch("INSERT INTO threads VALUES ('legacy', 'subagent', NULL); INSERT INTO thread_spawn_edges VALUES ('first-parent', 'legacy');").unwrap();
|
||||
second.execute_batch("INSERT INTO threads VALUES ('legacy', 'subagent', NULL); INSERT INTO thread_spawn_edges VALUES ('second-parent', 'legacy');").unwrap();
|
||||
let identities = usage_thread_identities(&dir);
|
||||
assert_eq!(
|
||||
identities["conflict"],
|
||||
UsageThreadIdentity {
|
||||
classification_known: true,
|
||||
is_subagent: true,
|
||||
parent_id: None,
|
||||
parent_conflict: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(identities["legacy"], identities["conflict"]);
|
||||
drop(active);
|
||||
drop(first);
|
||||
drop(second);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_thread_identity_missing_corrupt_and_locked_databases_are_best_effort() {
|
||||
let dir = temp_codex_dir("usage-identities-unavailable");
|
||||
assert!(usage_thread_identities(&dir.join("missing")).is_empty());
|
||||
assert!(!dir.join("missing").exists());
|
||||
fs::write(
|
||||
dir.join("state_12.sqlite"),
|
||||
b"SQLite format 3\0broken fixture",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(usage_thread_identities(&dir).is_empty());
|
||||
let locked = create_identity_database(&dir.join("state_10.sqlite"));
|
||||
locked
|
||||
.execute_batch("INSERT INTO threads VALUES ('user', 'user', NULL); BEGIN EXCLUSIVE;")
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
assert!(usage_thread_identities(&dir).is_empty());
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
locked.execute_batch("ROLLBACK;").unwrap();
|
||||
assert!(!usage_thread_identities(&dir)["user"].is_subagent);
|
||||
drop(locked);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_session_titles_follow_chat_title_fallbacks_and_only_requested_ids() {
|
||||
let codex_dir = temp_codex_dir("usage-title-fields");
|
||||
let conn = create_title_database(&codex_dir.join("state_10.sqlite"));
|
||||
conn.execute_batch(
|
||||
"INSERT INTO threads VALUES
|
||||
('named', ' 真实聊天标题 ', 'First message', 'Preview', '/projects/unused'),
|
||||
('first', ' ', ' 首条用户消息 ', 'Preview', NULL),
|
||||
('preview', NULL, ' ', ' 会话摘要 ', NULL),
|
||||
('project', NULL, NULL, NULL, 'C:\\work\\Codex-X\\'),
|
||||
('invalid-title', X'FF', ' 可读取的首条消息 ', NULL, NULL),
|
||||
('invalid-values', 42, 3.14, X'FF', NULL),
|
||||
('root', NULL, NULL, NULL, '/'),
|
||||
('excluded', 'This row was not requested', NULL, NULL, NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
let ids = [
|
||||
"named",
|
||||
"first",
|
||||
"preview",
|
||||
"project",
|
||||
"invalid-title",
|
||||
"invalid-values",
|
||||
"root",
|
||||
"x' OR 1=1 --",
|
||||
]
|
||||
.map(ToString::to_string);
|
||||
let titles = session_titles_by_id(&codex_dir, &ids);
|
||||
assert_eq!(titles.len(), 5);
|
||||
assert_eq!(titles["named"], "真实聊天标题");
|
||||
assert_eq!(titles["first"], "首条用户消息");
|
||||
assert_eq!(titles["preview"], "会话摘要");
|
||||
assert_eq!(titles["project"], "Codex-X");
|
||||
assert_eq!(titles["invalid-title"], "可读取的首条消息");
|
||||
assert!(!titles.contains_key("excluded"));
|
||||
assert!(!titles.contains_key("invalid-values"));
|
||||
assert!(!titles.contains_key("root"));
|
||||
drop(conn);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_session_titles_prefer_active_database_and_reread_renames() {
|
||||
let codex_dir = temp_codex_dir("usage-title-priority");
|
||||
let active = create_title_database(&codex_dir.join("state_10.sqlite"));
|
||||
let legacy = create_title_database(&codex_dir.join("sqlite/state_5.sqlite"));
|
||||
active
|
||||
.execute_batch(
|
||||
"INSERT INTO threads VALUES
|
||||
('shared', 'Current title', NULL, NULL, NULL),
|
||||
('fallback', ' ', NULL, NULL, '/projects/active-project'),
|
||||
('project', NULL, NULL, NULL, '/projects/active-project');",
|
||||
)
|
||||
.unwrap();
|
||||
legacy
|
||||
.execute_batch(
|
||||
"INSERT INTO threads VALUES
|
||||
('shared', 'Old title', NULL, NULL, NULL),
|
||||
('fallback', 'Recovered chat title', NULL, NULL, '/projects/old-project'),
|
||||
('project', NULL, NULL, NULL, '/projects/old-project');",
|
||||
)
|
||||
.unwrap();
|
||||
let ids = ["shared", "fallback", "project"].map(ToString::to_string);
|
||||
let titles = session_titles_by_id(&codex_dir, &ids);
|
||||
assert_eq!(titles["shared"], "Current title");
|
||||
assert_eq!(titles["fallback"], "Recovered chat title");
|
||||
assert_eq!(titles["project"], "active-project");
|
||||
active
|
||||
.execute(
|
||||
"UPDATE threads SET title = 'Renamed chat title' WHERE id = 'shared'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session_titles_by_id(&codex_dir, &ids)["shared"],
|
||||
"Renamed chat title"
|
||||
);
|
||||
drop(active);
|
||||
drop(legacy);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_session_titles_are_read_only_and_tolerate_missing_corrupt_or_locked_databases() {
|
||||
let codex_dir = temp_codex_dir("usage-title-read-only");
|
||||
let missing = codex_dir.join("missing");
|
||||
let ids = ["session".to_string()];
|
||||
assert!(session_titles_by_id(&missing, &ids).is_empty());
|
||||
assert!(!missing.exists());
|
||||
let path = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&path, "session", "openai");
|
||||
fs::write(
|
||||
codex_dir.join("state_12.sqlite"),
|
||||
b"SQLite format 3\0broken fixture",
|
||||
)
|
||||
.unwrap();
|
||||
let before = fs::read(&path).unwrap();
|
||||
let modified = fs::metadata(&path).unwrap().modified().unwrap();
|
||||
let original_permissions = fs::metadata(&path).unwrap().permissions();
|
||||
let mut permissions = original_permissions.clone();
|
||||
permissions.set_readonly(true);
|
||||
fs::set_permissions(&path, permissions.clone()).unwrap();
|
||||
assert_eq!(
|
||||
session_titles_by_id(&codex_dir, &ids)["session"],
|
||||
"test session"
|
||||
);
|
||||
assert_eq!(fs::read(&path).unwrap(), before);
|
||||
assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), modified);
|
||||
assert!(!codex_dir.join("state_10.sqlite-journal").exists());
|
||||
fs::set_permissions(&path, original_permissions).unwrap();
|
||||
let locked = Connection::open(&path).unwrap();
|
||||
locked.execute_batch("BEGIN EXCLUSIVE;").unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
assert!(session_titles_by_id(&codex_dir, &ids).is_empty());
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
locked.execute_batch("ROLLBACK;").unwrap();
|
||||
drop(locked);
|
||||
fs::remove_dir_all(codex_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_project_titles_support_platform_paths_and_reject_roots() {
|
||||
for (path, expected) in [
|
||||
("/Users/test/projects/Codex-X/", Some("Codex-X")),
|
||||
(r"C:\work\Codex-X\", Some("Codex-X")),
|
||||
(r"\\?\C:\work\Codex-X", Some("Codex-X")),
|
||||
(r"\\server\share\project", Some("project")),
|
||||
("relative/project", Some("project")),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("/", None),
|
||||
(r"C:\", None),
|
||||
("C:", None),
|
||||
(r"\\server\share\", None),
|
||||
(".", None),
|
||||
("..", None),
|
||||
] {
|
||||
assert_eq!(session_project_title(path).as_deref(), expected, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_state_10_honors_an_explicit_provider_target() {
|
||||
let codex_dir = temp_codex_dir("root-state-10");
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
|
||||
use super::catalog::{scan_catalog_sync, CatalogSyncScan};
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
|
||||
list_session_previews_with_paths, scan_rollouts, scan_rollouts_for_thread_ids,
|
||||
list_session_previews_with_paths, scan_provider_rollouts, scan_rollouts_for_thread_ids,
|
||||
scan_sqlite_with_paths, SqliteDiscovery,
|
||||
};
|
||||
use super::transaction::{
|
||||
@@ -35,7 +35,7 @@ fn scan_provider_buckets(
|
||||
let mut rollouts = scan_rollouts_for_thread_ids(
|
||||
codex_dir,
|
||||
target_provider,
|
||||
&sqlite.thread_ids,
|
||||
&sqlite.syncable_thread_ids,
|
||||
&sqlite.rollout_paths_by_thread_id,
|
||||
)?;
|
||||
// Provider synchronization must not repair or rewrite the independent cwd index.
|
||||
@@ -75,6 +75,7 @@ struct ProviderSyncScan {
|
||||
sqlite: SqliteScan,
|
||||
rollouts: RolloutScan,
|
||||
catalog: CatalogSyncScan,
|
||||
syncable_thread_ids: HashSet<String>,
|
||||
scan_failures: Vec<String>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
@@ -89,24 +90,52 @@ fn scan_provider_sync_data(
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let sqlite = scan_sqlite_with_paths(
|
||||
let mut sqlite = scan_sqlite_with_paths(
|
||||
&discovery.thread_paths,
|
||||
&RolloutScan::default(),
|
||||
target_provider,
|
||||
)?;
|
||||
let mut syncable_thread_ids = sqlite.syncable_thread_ids.clone();
|
||||
let mut archived_thread_ids = sqlite.archived_thread_ids.clone();
|
||||
let mut subagent_thread_ids = sqlite.subagent_thread_ids.clone();
|
||||
let mut mismatched_thread_ids = sqlite.mismatched_thread_ids.clone();
|
||||
if active_sqlite.sqlite_dbs > 0 {
|
||||
syncable_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
archived_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
subagent_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
mismatched_thread_ids.retain(|id| !active_sqlite.thread_ids.contains(id));
|
||||
syncable_thread_ids.extend(active_sqlite.syncable_thread_ids.iter().cloned());
|
||||
archived_thread_ids.extend(active_sqlite.archived_thread_ids.iter().cloned());
|
||||
subagent_thread_ids.extend(active_sqlite.subagent_thread_ids.iter().cloned());
|
||||
mismatched_thread_ids.extend(active_sqlite.mismatched_thread_ids.iter().cloned());
|
||||
}
|
||||
let mut excluded_thread_ids = archived_thread_ids.clone();
|
||||
excluded_thread_ids.extend(subagent_thread_ids.iter().cloned());
|
||||
// Without an active authority, conflicting legacy classifications stay conservatively excluded.
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
mismatched_thread_ids.retain(|id| syncable_thread_ids.contains(id));
|
||||
sqlite.syncable_thread_ids = syncable_thread_ids.clone();
|
||||
sqlite.archived_thread_ids = archived_thread_ids;
|
||||
sqlite.subagent_thread_ids = subagent_thread_ids;
|
||||
sqlite.mismatched_thread_ids = mismatched_thread_ids;
|
||||
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
|
||||
|
||||
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
|
||||
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
|
||||
} else {
|
||||
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
|
||||
};
|
||||
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
|
||||
let mut rollouts = scan_rollouts(codex_dir, target_provider)?;
|
||||
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &excluded_thread_ids)?;
|
||||
// Provider synchronization changes provider routing only; cwd remains independently managed.
|
||||
rollouts.cwd_by_thread_id.clear();
|
||||
syncable_thread_ids.extend(rollouts.thread_ids.iter().cloned());
|
||||
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
|
||||
let catalog = scan_catalog_sync(
|
||||
&discovery.thread_paths,
|
||||
&discovery.related_paths,
|
||||
target_provider,
|
||||
&syncable_thread_ids,
|
||||
)?;
|
||||
|
||||
let mut scan_failures = discovery.active_scan_failures.clone();
|
||||
@@ -143,6 +172,7 @@ fn scan_provider_sync_data(
|
||||
sqlite,
|
||||
rollouts,
|
||||
catalog,
|
||||
syncable_thread_ids,
|
||||
scan_failures,
|
||||
warnings,
|
||||
})
|
||||
@@ -217,7 +247,7 @@ pub(super) fn session_sync_status_with_discovery(
|
||||
let mut mismatched_ids = sqlite_mismatch_ids.clone();
|
||||
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
|
||||
for session in &mut sessions {
|
||||
if mismatched_ids.contains(&session.id) {
|
||||
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
|
||||
session.needs_sync = true;
|
||||
}
|
||||
}
|
||||
@@ -314,13 +344,13 @@ where
|
||||
if !initial_status.scan_complete {
|
||||
return Err(scan_failure_error(&initial_status.scan_failures));
|
||||
}
|
||||
let scan = scan_provider_sync_data(&codex_dir, &target_provider, &discovery)?;
|
||||
if !scan.scan_failures.is_empty() {
|
||||
return Err(scan_failure_error(&scan.scan_failures));
|
||||
let preflight_scan = scan_provider_sync_data(&codex_dir, &target_provider, &discovery)?;
|
||||
if !preflight_scan.scan_failures.is_empty() {
|
||||
return Err(scan_failure_error(&preflight_scan.scan_failures));
|
||||
}
|
||||
if scan.rollouts.changes.is_empty()
|
||||
&& scan.sqlite.mismatched_threads == 0
|
||||
&& scan.catalog.total_updates() == 0
|
||||
if preflight_scan.rollouts.changes.is_empty()
|
||||
&& preflight_scan.sqlite.mismatched_threads == 0
|
||||
&& preflight_scan.catalog.total_updates() == 0
|
||||
{
|
||||
return Ok(SessionSyncResult {
|
||||
status: initial_status,
|
||||
@@ -330,13 +360,38 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
hook(MutationPoint::BeforeSqliteLock)?;
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
|
||||
let scan = match scan_provider_sync_data(&codex_dir, &target_provider, &discovery) {
|
||||
Ok(scan) => scan,
|
||||
Err(error) => {
|
||||
rollback_open_transactions(&mut pending_sqlite);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if !scan.scan_failures.is_empty() {
|
||||
rollback_open_transactions(&mut pending_sqlite);
|
||||
return Err(scan_failure_error(&scan.scan_failures));
|
||||
}
|
||||
if scan.rollouts.changes.is_empty()
|
||||
&& scan.sqlite.mismatched_threads == 0
|
||||
&& scan.catalog.total_updates() == 0
|
||||
{
|
||||
rollback_open_transactions(&mut pending_sqlite);
|
||||
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
|
||||
return Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: 0,
|
||||
updated_threads: 0,
|
||||
backup_dir: String::new(),
|
||||
});
|
||||
}
|
||||
let changed_rollouts = scan
|
||||
.rollouts
|
||||
.changes
|
||||
.iter()
|
||||
.map(|change| change.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
|
||||
let sqlite_snapshot_paths = pending_sqlite
|
||||
.iter()
|
||||
.map(|update| update.path().to_path_buf())
|
||||
@@ -359,6 +414,7 @@ where
|
||||
&mut pending_sqlite,
|
||||
&target_provider,
|
||||
&scan.catalog.sources,
|
||||
&scan.syncable_thread_ids,
|
||||
&mut journal,
|
||||
&mut hook,
|
||||
);
|
||||
@@ -434,7 +490,8 @@ mod tests {
|
||||
"CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_provider TEXT NOT NULL,
|
||||
title TEXT
|
||||
title TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.expect("create threads table");
|
||||
@@ -455,7 +512,8 @@ mod tests {
|
||||
id TEXT PRIMARY KEY,
|
||||
model_provider TEXT NOT NULL,
|
||||
title TEXT,
|
||||
rollout_path TEXT
|
||||
rollout_path TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.expect("create threads table with rollout path");
|
||||
@@ -546,6 +604,29 @@ mod tests {
|
||||
.expect("read thread provider")
|
||||
}
|
||||
|
||||
fn thread_provider_and_archived(path: &Path, id: &str) -> (String, i64) {
|
||||
Connection::open(path)
|
||||
.expect("open session database")
|
||||
.query_row(
|
||||
"SELECT model_provider, archived FROM threads WHERE id = ?1",
|
||||
[id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.expect("read thread provider and archive state")
|
||||
}
|
||||
|
||||
fn catalog_provider_and_visibility(path: &Path, id: &str) -> Option<(String, i64)> {
|
||||
Connection::open(path)
|
||||
.expect("open catalog database")
|
||||
.query_row(
|
||||
"SELECT model_provider, missing_candidate FROM local_thread_catalog \
|
||||
WHERE thread_id = ?1",
|
||||
[id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn write_rollout(codex_dir: &Path, id: &str, provider: &str) -> std::path::PathBuf {
|
||||
let path = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
write_rollout_at(&path, id, provider);
|
||||
@@ -563,6 +644,34 @@ mod tests {
|
||||
.expect("write rollout");
|
||||
}
|
||||
|
||||
fn write_subagent_rollout(
|
||||
codex_dir: &Path,
|
||||
id: &str,
|
||||
parent_id: &str,
|
||||
provider: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let path = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
fs::create_dir_all(path.parent().expect("rollout parent")).expect("create rollout parent");
|
||||
let record = serde_json::json!({
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": id,
|
||||
"model_provider": provider,
|
||||
"source": {
|
||||
"subagent": {
|
||||
"thread_spawn": {
|
||||
"parent_thread_id": parent_id,
|
||||
"depth": 1,
|
||||
"agent_path": "/root/test-agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
fs::write(&path, format!("{record}\n")).expect("write subagent rollout");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_provider_is_used_as_the_sync_target() {
|
||||
let codex_dir = temp_codex_dir("live-provider-target");
|
||||
@@ -963,6 +1072,68 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_user_thread_overrides_stale_legacy_subagent_marker() {
|
||||
let codex_dir = temp_codex_dir("active-user-legacy-subagent");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000575";
|
||||
let active = codex_dir.join("state_5.sqlite");
|
||||
let legacy = codex_dir.join("sqlite/state_5.sqlite");
|
||||
for database in [&active, &legacy] {
|
||||
create_thread_database(database, id, "openai");
|
||||
Connection::open(database)
|
||||
.expect("open thread classification database")
|
||||
.execute_batch("ALTER TABLE threads ADD COLUMN thread_source TEXT;")
|
||||
.expect("add thread source column");
|
||||
}
|
||||
Connection::open(&active)
|
||||
.expect("open active user database")
|
||||
.execute(
|
||||
"UPDATE threads SET thread_source = 'user' WHERE id = ?1",
|
||||
[id],
|
||||
)
|
||||
.expect("mark active thread as user");
|
||||
Connection::open(&legacy)
|
||||
.expect("open legacy subagent database")
|
||||
.execute(
|
||||
"UPDATE threads SET thread_source = 'subagent' WHERE id = ?1",
|
||||
[id],
|
||||
)
|
||||
.expect("mark legacy thread as subagent");
|
||||
let rollout = write_rollout(&codex_dir, id, "openai");
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan active user with stale legacy subagent marker");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.subagent_threads, 0);
|
||||
assert_eq!(status.mismatched_threads, 1);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
let preview = status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == id)
|
||||
.expect("active user preview");
|
||||
assert!(!preview.is_subagent);
|
||||
assert!(preview.needs_sync);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize authoritative active user thread");
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert!(result.updated_threads > 0);
|
||||
assert_eq!(thread_provider(&active, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(thread_provider(&legacy, id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(catalog_provider(&catalog, id), SHARED_SESSION_PROVIDER);
|
||||
assert!(fs::read_to_string(rollout)
|
||||
.expect("read synchronized active user rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_legacy_rollout_reference_warns_without_blocking_active_sync() {
|
||||
let codex_dir = temp_codex_dir("stale-legacy-rollout");
|
||||
@@ -1048,6 +1219,47 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_legacy_archive_state_is_not_syncable_without_active_database() {
|
||||
let codex_dir = temp_codex_dir("legacy-archive-conflict");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000522";
|
||||
let archived_database = codex_dir.join("sqlite/state_4.sqlite");
|
||||
let active_database = codex_dir.join("sqlite/state_5.sqlite");
|
||||
create_thread_database(&archived_database, id, "openai");
|
||||
create_thread_database(&active_database, id, "openai");
|
||||
Connection::open(&archived_database)
|
||||
.expect("open archived legacy database")
|
||||
.execute("UPDATE threads SET archived = 1 WHERE id = ?1", [id])
|
||||
.expect("archive legacy thread");
|
||||
let rollout = write_rollout(&codex_dir, id, "openai");
|
||||
let original_rollout = fs::read(&rollout).expect("read conflicting legacy rollout");
|
||||
let catalog = codex_dir.join("sqlite/catalog.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan conflicting legacy archive state");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.mismatched_sessions, 0);
|
||||
assert!(status.sessions.iter().all(|session| !session.needs_sync));
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("conflicting legacy state is a no-op");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert_eq!(thread_provider(&archived_database, id), "openai");
|
||||
assert_eq!(thread_provider(&active_database, id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(rollout).expect("read preserved conflicting legacy rollout"),
|
||||
original_rollout
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_rollout_is_synchronized_like_codex_plusplus() {
|
||||
let codex_dir = temp_codex_dir("orphan-rollout");
|
||||
@@ -1078,6 +1290,485 @@ mod tests {
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_orphan_catalog_row_is_synchronized() {
|
||||
let codex_dir = temp_codex_dir("orphan-catalog");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let indexed_id = "019f6000-0000-7000-8000-000000000539";
|
||||
create_thread_database(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
indexed_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
let orphan_id = "019f6000-0000-7000-8000-000000000540";
|
||||
let orphan = write_rollout(&codex_dir, orphan_id, SHARED_SESSION_PROVIDER);
|
||||
let original_rollout = fs::read(&orphan).expect("read matching orphan rollout");
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[(indexed_id, SHARED_SESSION_PROVIDER), (orphan_id, "openai")],
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan orphan catalog mismatch");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.mismatched_rollouts, 0);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize orphan catalog row");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(
|
||||
catalog_provider(&catalog, orphan_id),
|
||||
SHARED_SESSION_PROVIDER
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(orphan).expect("read preserved matching orphan rollout"),
|
||||
original_rollout
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_thread_is_visible_but_not_a_sync_candidate() {
|
||||
let codex_dir = temp_codex_dir("archived-not-syncable");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000533";
|
||||
let rollout = codex_dir.join(format!(
|
||||
"archived_sessions/2026/08/18/rollout-test-{id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&rollout, id, "openai");
|
||||
let original_rollout = fs::read(&rollout).expect("read archived rollout");
|
||||
let state = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&state, id, "openai", &rollout);
|
||||
Connection::open(&state)
|
||||
.expect("open archived thread database")
|
||||
.execute("UPDATE threads SET archived = 1 WHERE id = ?1", [id])
|
||||
.expect("archive thread");
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
Connection::open(&catalog)
|
||||
.expect("open archived catalog")
|
||||
.execute(
|
||||
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
|
||||
[id],
|
||||
)
|
||||
.expect("hide archived catalog row");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan archived thread");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.mismatched_threads, 0);
|
||||
assert_eq!(status.mismatched_rollouts, 0);
|
||||
assert_eq!(status.mismatched_sessions, 0);
|
||||
let preview = status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == id)
|
||||
.expect("archived preview remains visible");
|
||||
assert!(preview.archived);
|
||||
assert!(!preview.needs_sync);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("archived-only sync is a no-op");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert_eq!(
|
||||
thread_provider_and_archived(&state, id),
|
||||
("openai".to_string(), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_provider_and_visibility(&catalog, id),
|
||||
Some(("openai".to_string(), 1))
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&rollout).expect("read preserved archived rollout"),
|
||||
original_rollout
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagents_are_visible_but_excluded_from_provider_sync() {
|
||||
let codex_dir = temp_codex_dir("subagents-not-syncable");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let parent_id = "019f6000-0000-7000-8000-000000000570";
|
||||
let edge_child_id = "019f6000-0000-7000-8000-000000000571";
|
||||
let source_child_id = "019f6000-0000-7000-8000-000000000572";
|
||||
let parent_rollout = write_rollout(&codex_dir, parent_id, SHARED_SESSION_PROVIDER);
|
||||
let edge_child_rollout =
|
||||
write_subagent_rollout(&codex_dir, edge_child_id, parent_id, "openai");
|
||||
let source_child_rollout =
|
||||
write_subagent_rollout(&codex_dir, source_child_id, parent_id, "openai");
|
||||
let edge_child_original = fs::read(&edge_child_rollout).expect("read edge child rollout");
|
||||
let source_child_original =
|
||||
fs::read(&source_child_rollout).expect("read source child rollout");
|
||||
|
||||
let state = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(
|
||||
&state,
|
||||
parent_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
&parent_rollout,
|
||||
);
|
||||
let state_conn = Connection::open(&state).expect("open subagent thread database");
|
||||
state_conn
|
||||
.execute_batch(
|
||||
"ALTER TABLE threads ADD COLUMN thread_source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN source TEXT;
|
||||
CREATE TABLE thread_spawn_edges (
|
||||
parent_thread_id TEXT NOT NULL,
|
||||
child_thread_id TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("add subagent metadata");
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path)
|
||||
VALUES (?1, 'openai', 'edge child', ?2)",
|
||||
(edge_child_id, edge_child_rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert edge child thread");
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id)
|
||||
VALUES (?1, ?2)",
|
||||
(parent_id, edge_child_id),
|
||||
)
|
||||
.expect("insert child spawn edge");
|
||||
let source = serde_json::json!({
|
||||
"subagent": {
|
||||
"thread_spawn": {
|
||||
"parent_thread_id": parent_id,
|
||||
"depth": 1
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path, source)
|
||||
VALUES (?1, 'openai', 'source child', ?2, ?3)",
|
||||
(
|
||||
source_child_id,
|
||||
source_child_rollout.display().to_string(),
|
||||
source,
|
||||
),
|
||||
)
|
||||
.expect("insert source child thread");
|
||||
drop(state_conn);
|
||||
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[
|
||||
(parent_id, SHARED_SESSION_PROVIDER),
|
||||
(edge_child_id, "openai"),
|
||||
(source_child_id, "openai"),
|
||||
],
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan subagent threads");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.sqlite_threads, 3);
|
||||
assert_eq!(status.top_level_threads, 1);
|
||||
assert_eq!(status.subagent_threads, 2);
|
||||
assert_eq!(status.mismatched_threads, 0);
|
||||
assert_eq!(status.mismatched_rollouts, 0);
|
||||
assert_eq!(status.mismatched_session_meta, 0);
|
||||
assert_eq!(status.mismatched_sessions, 0);
|
||||
for child_id in [edge_child_id, source_child_id] {
|
||||
let preview = status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == child_id)
|
||||
.expect("subagent preview remains visible");
|
||||
assert!(preview.is_subagent);
|
||||
assert!(!preview.needs_sync);
|
||||
}
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("subagent-only mismatch is a no-op");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert_eq!(thread_provider(&state, edge_child_id), "openai");
|
||||
assert_eq!(thread_provider(&state, source_child_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, edge_child_id), "openai");
|
||||
assert_eq!(catalog_provider(&catalog, source_child_id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(edge_child_rollout).expect("read preserved edge child rollout"),
|
||||
edge_child_original
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(source_child_rollout).expect("read preserved source child rollout"),
|
||||
source_child_original
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_only_subagent_source_is_excluded_from_provider_sync() {
|
||||
let codex_dir = temp_codex_dir("rollout-only-subagent");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let parent_id = "019f6000-0000-7000-8000-000000000573";
|
||||
let child_id = "019f6000-0000-7000-8000-000000000574";
|
||||
create_thread_database(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
parent_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, parent_id, SHARED_SESSION_PROVIDER);
|
||||
let child_rollout = write_subagent_rollout(&codex_dir, child_id, parent_id, "openai");
|
||||
let child_original = fs::read(&child_rollout).expect("read rollout-only subagent");
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[(parent_id, SHARED_SESSION_PROVIDER), (child_id, "openai")],
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan rollout-only subagent");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.mismatched_threads, 0);
|
||||
assert_eq!(status.mismatched_rollouts, 0);
|
||||
assert_eq!(status.mismatched_session_meta, 0);
|
||||
assert_eq!(status.mismatched_sessions, 0);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("rollout-only subagent is a no-op");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert_eq!(catalog_provider(&catalog, child_id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(child_rollout).expect("read preserved rollout-only subagent"),
|
||||
child_original
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_between_scan_and_sqlite_lock_is_excluded_from_sync() {
|
||||
let codex_dir = temp_codex_dir("archive-during-sync");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000543";
|
||||
let rollout = write_rollout(&codex_dir, id, "openai");
|
||||
let original_rollout = fs::read(&rollout).expect("read pre-archive rollout");
|
||||
let state = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&state, id, "openai", &rollout);
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[(id, "openai")]);
|
||||
let mut archive_hook_called = false;
|
||||
|
||||
let result = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
None,
|
||||
|point| match point {
|
||||
MutationPoint::BeforeSqliteLock => {
|
||||
archive_hook_called = true;
|
||||
Connection::open(&state)
|
||||
.expect("open thread database before sync lock")
|
||||
.execute("UPDATE threads SET archived = 1 WHERE id = ?1", [id])
|
||||
.expect("archive thread before sync lock");
|
||||
Connection::open(&catalog)
|
||||
.expect("open catalog before sync lock")
|
||||
.execute(
|
||||
"UPDATE local_thread_catalog SET missing_candidate = 1 \
|
||||
WHERE thread_id = ?1",
|
||||
[id],
|
||||
)
|
||||
.expect("hide catalog row before sync lock");
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect("newly archived thread is a no-op");
|
||||
|
||||
assert!(archive_hook_called);
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert!(!result.status.needs_sync);
|
||||
let preview = result
|
||||
.status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == id)
|
||||
.expect("newly archived preview remains visible");
|
||||
assert!(preview.archived);
|
||||
assert!(!preview.needs_sync);
|
||||
assert_eq!(
|
||||
thread_provider_and_archived(&state, id),
|
||||
("openai".to_string(), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_provider_and_visibility(&catalog, id),
|
||||
Some(("openai".to_string(), 1))
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(rollout).expect("read preserved newly archived rollout"),
|
||||
original_rollout
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_repairs_active_thread_without_mutating_archived_storage_or_catalog() {
|
||||
let codex_dir = temp_codex_dir("mixed-active-archived");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000534";
|
||||
let hidden_archived_id = "019f6000-0000-7000-8000-000000000535";
|
||||
let missing_archived_id = "019f6000-0000-7000-8000-000000000536";
|
||||
let active_rollout = write_rollout(&codex_dir, active_id, "openai");
|
||||
let hidden_archived_rollout = codex_dir.join(format!(
|
||||
"archived_sessions/2026/08/18/rollout-test-{hidden_archived_id}.jsonl"
|
||||
));
|
||||
let missing_archived_rollout = codex_dir.join(format!(
|
||||
"archived_sessions/2026/08/18/rollout-test-{missing_archived_id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&hidden_archived_rollout, hidden_archived_id, "openai");
|
||||
write_rollout_at(&missing_archived_rollout, missing_archived_id, "openai");
|
||||
let hidden_original =
|
||||
fs::read(&hidden_archived_rollout).expect("read hidden archived rollout");
|
||||
let missing_original =
|
||||
fs::read(&missing_archived_rollout).expect("read missing archived rollout");
|
||||
|
||||
let state = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&state, active_id, "openai", &active_rollout);
|
||||
let state_conn = Connection::open(&state).expect("open mixed thread database");
|
||||
for (id, rollout) in [
|
||||
(hidden_archived_id, &hidden_archived_rollout),
|
||||
(missing_archived_id, &missing_archived_rollout),
|
||||
] {
|
||||
state_conn
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path, archived) \
|
||||
VALUES (?1, 'openai', 'archived', ?2, 1)",
|
||||
(id, rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert archived thread");
|
||||
}
|
||||
drop(state_conn);
|
||||
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(
|
||||
&catalog,
|
||||
&[(active_id, "openai"), (hidden_archived_id, "openai")],
|
||||
);
|
||||
Connection::open(&catalog)
|
||||
.expect("open mixed catalog")
|
||||
.execute(
|
||||
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
|
||||
[hidden_archived_id],
|
||||
)
|
||||
.expect("hide archived catalog row");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan mixed threads");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
assert!(status
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|session| session.archived)
|
||||
.all(|session| !session.needs_sync));
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync active thread only");
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(thread_provider(&state, active_id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(
|
||||
thread_provider_and_archived(&state, hidden_archived_id),
|
||||
("openai".to_string(), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
thread_provider_and_archived(&state, missing_archived_id),
|
||||
("openai".to_string(), 1)
|
||||
);
|
||||
assert!(fs::read_to_string(&active_rollout)
|
||||
.expect("read synchronized active rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
assert_eq!(
|
||||
fs::read(&hidden_archived_rollout).expect("read preserved hidden archived rollout"),
|
||||
hidden_original
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&missing_archived_rollout).expect("read preserved missing archived rollout"),
|
||||
missing_original
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_provider(&catalog, active_id),
|
||||
SHARED_SESSION_PROVIDER
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_provider_and_visibility(&catalog, hidden_archived_id),
|
||||
Some(("openai".to_string(), 1))
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_provider_and_visibility(&catalog, missing_archived_id),
|
||||
None
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_orphan_rollout_is_ignored_while_active_orphan_still_syncs() {
|
||||
let codex_dir = temp_codex_dir("orphan-storage-scope");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let indexed_id = "019f6000-0000-7000-8000-000000000537";
|
||||
create_thread_database(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
indexed_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
let active_orphan_id = "019f6000-0000-7000-8000-000000000538";
|
||||
let archived_orphan_id = "019f6000-0000-7000-8000-000000000539";
|
||||
let active_orphan = write_rollout(&codex_dir, active_orphan_id, "openai");
|
||||
let archived_orphan = codex_dir.join(format!(
|
||||
"archived_sessions/2026/08/18/rollout-test-{archived_orphan_id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&archived_orphan, archived_orphan_id, "openai");
|
||||
let archived_original = fs::read(&archived_orphan).expect("read archived orphan");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan orphan storage scope");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.mismatched_rollouts, 1);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync active orphan only");
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert!(fs::read_to_string(active_orphan)
|
||||
.expect("read synchronized active orphan")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
assert_eq!(
|
||||
fs::read(archived_orphan).expect("read preserved archived orphan"),
|
||||
archived_original
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_count_is_the_union_of_active_session_ids() {
|
||||
let codex_dir = temp_codex_dir("mismatch-union");
|
||||
|
||||
@@ -177,54 +177,73 @@ fn apply_sqlite_updates(
|
||||
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() {
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
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
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}
|
||||
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()))?;
|
||||
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(&snapshot_sql, (target_provider, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows += update
|
||||
.conn
|
||||
.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(
|
||||
"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, (cwd, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.cwd_rows += update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
|
||||
(cwd, thread_id),
|
||||
)
|
||||
.execute(&update_sql, (cwd, thread_id))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
@@ -233,6 +252,7 @@ fn apply_sqlite_updates(
|
||||
&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;
|
||||
@@ -381,6 +401,7 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
BeforeSqliteLock,
|
||||
AfterSqliteCommit(usize),
|
||||
}
|
||||
|
||||
@@ -395,6 +416,7 @@ pub(super) fn execute_provider_sync_mutation<F>(
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
target_provider: &str,
|
||||
catalog_sources: &HashMap<String, CatalogRepairThread>,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
journal: &mut MutationJournal,
|
||||
hook: &mut F,
|
||||
) -> Result<MutationResult>
|
||||
@@ -404,7 +426,13 @@ 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, catalog_sources)?;
|
||||
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 {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
|
||||
@@ -60,6 +60,7 @@ pub(crate) struct RolloutScan {
|
||||
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>,
|
||||
@@ -81,6 +82,9 @@ pub(crate) struct SqliteScan {
|
||||
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>,
|
||||
|
||||
@@ -297,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,
|
||||
@@ -486,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,8 +7,9 @@ use crate::prompts::{
|
||||
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
|
||||
};
|
||||
use crate::providers::{
|
||||
detected_live_custom_provider, document_is_official, list_saved_providers_inner,
|
||||
official_auth_available, unique_saved_provider_id_for_live, SavedProvider,
|
||||
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;
|
||||
@@ -31,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,
|
||||
@@ -45,7 +46,8 @@ pub(crate) struct CodexState {
|
||||
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>,
|
||||
@@ -159,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
|
||||
@@ -175,8 +177,17 @@ 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)]
|
||||
@@ -210,13 +221,17 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
};
|
||||
let instruction_enabled = instruction_template_key.is_some();
|
||||
let providers = extract_providers(&doc, model_provider.as_deref());
|
||||
let saved_providers = list_saved_providers_inner()?;
|
||||
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)? {
|
||||
unique_saved_provider_id_for_live(&live, &saved_providers)
|
||||
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, &saved_providers)
|
||||
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
};
|
||||
|
||||
Ok(CodexState {
|
||||
@@ -235,6 +250,10 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
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)?,
|
||||
|
||||
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.10",
|
||||
"version": "0.3.16",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+473
-160
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
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,
|
||||
@@ -34,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;
|
||||
@@ -52,6 +57,7 @@ export type ProviderFormValue = {
|
||||
};
|
||||
|
||||
export type OfficialFormValue = {
|
||||
providerName: string;
|
||||
model: string;
|
||||
authJson: string;
|
||||
configText: string;
|
||||
@@ -68,6 +74,7 @@ export type ProviderCopy = {
|
||||
enableLabel: string;
|
||||
testLabel: string;
|
||||
editLabel: string;
|
||||
duplicateLabel: string;
|
||||
removeLabel: string;
|
||||
deleteTitle: string;
|
||||
deleteDescription: (providerName: string) => string;
|
||||
@@ -84,6 +91,7 @@ export type ProviderCopy = {
|
||||
officialAuthLabel: string;
|
||||
officialTomlLabel: string;
|
||||
officialSaveLabel: string;
|
||||
loadCcSwitchOfficialLabel: string;
|
||||
restoreOfficialLabel: string;
|
||||
resetOfficialLabel: string;
|
||||
resetOfficialTitle: string;
|
||||
@@ -133,8 +141,12 @@ 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;
|
||||
@@ -146,13 +158,18 @@ 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;
|
||||
@@ -185,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 = [
|
||||
@@ -239,8 +395,9 @@ function ListPage({
|
||||
onEnableProvider,
|
||||
onTestProvider,
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "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);
|
||||
@@ -315,7 +472,7 @@ function ListPage({
|
||||
>
|
||||
{copy.enableLabel}
|
||||
</button>
|
||||
{row.source === "official" && (
|
||||
{row.source === "official" && row.isDefaultOfficial && (
|
||||
<ActionIconButton
|
||||
icon={RotateCcw}
|
||||
label={copy.restoreOfficialLabel}
|
||||
@@ -334,6 +491,9 @@ function ListPage({
|
||||
{row.editable !== false && (
|
||||
<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={providerActionsBusy} danger />
|
||||
)}
|
||||
@@ -396,50 +556,81 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel, disabl
|
||||
}
|
||||
|
||||
function OfficialForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
officialForm,
|
||||
officialProfileIsDefault,
|
||||
canLoadCurrentOfficial,
|
||||
officialAuthRef,
|
||||
officialTomlRef,
|
||||
officialInfo,
|
||||
loading,
|
||||
actionBusy,
|
||||
onCancelMode,
|
||||
onOfficialNameChange,
|
||||
onOfficialModelChange,
|
||||
onOfficialAuthChange,
|
||||
onOfficialConfigChange,
|
||||
onSaveOfficial,
|
||||
onLoadCcSwitchOfficial,
|
||||
onLoadCurrentOfficial,
|
||||
onRestoreOfficial,
|
||||
onResetOfficial,
|
||||
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "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 formBusy = loading || actionBusy === "loadOfficialDraft";
|
||||
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} disabled={formBusy} />
|
||||
<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.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>
|
||||
<Field label={copy.officialTomlLabel} className="cx-providers-editor-field">
|
||||
<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}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
|
||||
<textarea
|
||||
ref={officialAuthRef}
|
||||
@@ -452,16 +643,32 @@ function OfficialForm({
|
||||
/>
|
||||
</Field>
|
||||
<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={onRestoreOfficial} disabled={formBusy}>
|
||||
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
|
||||
<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={() => setResetConfirmOpen(true)} disabled={formBusy}>
|
||||
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
|
||||
<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}
|
||||
open={resetConfirmOpen && showDefaultActions}
|
||||
onClose={() => setResetConfirmOpen(false)}
|
||||
title={copy.resetOfficialTitle}
|
||||
description={copy.resetOfficialDescription}
|
||||
@@ -484,7 +691,11 @@ function OfficialForm({
|
||||
}
|
||||
|
||||
function ProviderForm({
|
||||
lang,
|
||||
copy,
|
||||
creatingProvider,
|
||||
providerKind,
|
||||
onProviderKindChange,
|
||||
providerForm,
|
||||
loading,
|
||||
editingProviderId,
|
||||
@@ -506,10 +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;
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -521,6 +733,7 @@ function ProviderForm({
|
||||
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">
|
||||
@@ -610,9 +823,12 @@ 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} disabled={formBusy}><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)} disabled={formBusy} 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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -328,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;
|
||||
@@ -343,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;
|
||||
@@ -360,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;
|
||||
@@ -585,10 +673,6 @@
|
||||
border-color: #5944d5;
|
||||
}
|
||||
|
||||
.cx-prompts-heading > p {
|
||||
color: var(--cx-prompt-accent);
|
||||
}
|
||||
|
||||
.cx-prompts-mode-panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -1444,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;
|
||||
@@ -90,6 +114,7 @@ export type CodexState = {
|
||||
instructionTemplateKey?: string;
|
||||
agentsPath: string;
|
||||
activeSavedProviderId?: string;
|
||||
activeOfficialProfileId?: string | null;
|
||||
providers: ProviderSummary[];
|
||||
configText: string;
|
||||
authPreview?: unknown;
|
||||
@@ -111,6 +136,13 @@ export type OfficialConfigDraft = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type OfficialAuthCandidate = {
|
||||
authJson: string;
|
||||
configText?: string | null;
|
||||
model?: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ImportResult = {
|
||||
imported: number;
|
||||
added: number;
|
||||
@@ -130,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;
|
||||
@@ -184,6 +221,7 @@ export type ManagedSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
note?: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
@@ -199,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);
|
||||
});
|
||||
}
|
||||
+135
-7
@@ -8,6 +8,127 @@
|
||||
|
||||
日志不得包含 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:供应商认证往返、模板完整性与启动读取
|
||||
|
||||
### 决策
|
||||
@@ -15,21 +136,28 @@
|
||||
- 新建和 CC Switch 导入的供应商保存完整、无密钥的 TOML 模板;API Key 单独保存在供应商
|
||||
数据库字段中。完整模板是该供应商的权威配置,切换时恢复项目、插件、MCP、桌面、功能和
|
||||
环境设置;旧版稀疏模板仍以当前 live 配置为底稿兼容合并。
|
||||
- 默认切换行为对齐 CC Switch 的非“保留官方登录”路径:第三方 live `auth.json` 只写
|
||||
`OPENAI_API_KEY`,不写 `auth_mode`;live TOML 不保留 `experimental_bearer_token`。
|
||||
- 官方 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`。官方与第三方往返
|
||||
同时原子更新 config/auth,失败时回滚;未登录的官方 Reset 快照也必须保留完整 TOML。
|
||||
按目标方向写入:第三方凭据先于第三方路由,官方路由先于官方凭据;失败时只回滚已经写入
|
||||
的第一步。未登录的官方 Reset 快照也必须保留完整 TOML。自动捕获不得让第三方 API Key
|
||||
覆盖可信 OAuth 快照。
|
||||
- 兼容无 `auth_mode` 但包含有效 token、且不含 API Key 的旧版官方 OAuth;损坏的 live
|
||||
`auth.json` 不阻塞状态读取或被新供应商认证覆盖,但绝不能被晋升为官方认证快照。
|
||||
`auth.json` 与损坏的应用内官方快照都不阻塞状态读取或供应商切换,且绝不能被晋升为官方
|
||||
认证快照。完整官方 TOML 是模型字段的权威源,独立模型输入只补齐缺失值。
|
||||
- 启动状态读取不得迁移提示词、修改文件权限、捕获认证或扫描历史备份;概览在结果返回前显示
|
||||
“正在读取”,失败后显示“读取失败”。
|
||||
- Codex-X 内部数据库使用持久 `user_version`。已升级数据库的普通打开只读版本号;迁移、历史
|
||||
清理和版本更新在同一个 `BEGIN IMMEDIATE` 事务完成,失败可重试,同路径替换后可重新初始化。
|
||||
- Codex 版本探测在 blocking worker 中执行,固定候选先探测,目录候选流式探测,所有阶段共享
|
||||
同一个总 deadline,避免 Windows 慢盘或重定向用户目录拖住启动。
|
||||
- 对照 CC Switch `8673e9d8d8508b89c48056523c5f86e7916b4c3c` 的完整 provider config
|
||||
存储与默认 auth/config 写入路径;Codex-X 额外保留官方独立快照、live 配置的并发检查与
|
||||
原子回滚。本节决策替代 2026-07-29 中关于第三方 bearer token 和模板激活的旧约束。
|
||||
- 对照 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 配置并发与失败回滚
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.16",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Reference in New Issue
Block a user