mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aeacba04c | ||
|
|
a5c5e50436 | ||
|
|
81d259e276 |
@@ -2,6 +2,23 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
|
||||
## [v0.2.27] - 2026-07-07
|
||||
|
||||
- 优化【指令提示词】页面为 Skills/MCP 风格的简洁列表:左侧显示模板名称与说明,右侧使用开启/关闭切换;自定义/导入的 md 提示词增加编辑与删除图标。
|
||||
- 优化【供应商】页面操作区:移除“官方配置 / 本地保存 / gpt-5.5 / 不支持路由”等冗余信息,将切换改为“启用”,编辑/删除/测试连接改为图标按钮。
|
||||
- 新增供应商 base_url 连通性测试按钮,方便切换前检查第三方 API 地址是否可达。
|
||||
- 修复第三方 Provider 切换后可能未真正生效的问题:切换和保存 TOML 时会写入 Codex 实际读取的 `experimental_bearer_token`。
|
||||
- 对齐 cc-switch 的 Codex Provider live config 思路:从 cc-switch 导入时会识别 `experimental_bearer_token`,并避免使用 Codex 内置保留 provider id。
|
||||
- 优化启动加载链路:启动诊断、备份列表、会话同步状态改为后台刷新,减少首屏等待。
|
||||
|
||||
## [v0.2.26] - 2026-07-07
|
||||
|
||||
- 修复第三方 Provider 切换后可能“看起来已切换但实际未按新供应商生效”的问题:不再把所有第三方都写成 `model_provider = "custom"`,而是写入供应商自己的稳定 ID。
|
||||
- 修复从官方 ChatGPT 登录态切到第三方 API Key 时 `auth.json` 仍保留 `auth_mode = "chatgpt"` 的问题;写入 API Key 时会同步设置 `auth_mode = "api_key"`。
|
||||
- 第三方供应商默认不再要求 OpenAI 登录态,避免新建/导入 Provider 时错误继承官方 auth 语义。
|
||||
- 增加 Provider 切换单元测试,覆盖真实 provider key 和 API Key auth mode 的落盘结果。
|
||||
|
||||
## [v0.2.25] - 2026-07-06
|
||||
|
||||
- 进一步修复 TOML、指令提示词、供应商、会话管理等页面切换时右侧滚动条闪现/消失造成的视觉抖动。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.2.25",
|
||||
"version": "0.2.27",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -346,7 +346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.2.25"
|
||||
version = "0.2.26"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.2.25"
|
||||
version = "0.2.27"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::fs;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
mod constants;
|
||||
@@ -109,6 +110,7 @@ struct BackupEntry {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProviderInput {
|
||||
config_dir: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
provider_name: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
@@ -125,6 +127,14 @@ struct ProviderTomlInput {
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProviderConnectionResult {
|
||||
ok: bool,
|
||||
status: Option<u16>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OfficialConfigInput {
|
||||
@@ -1923,6 +1933,45 @@ fn sanitize_id(input: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn reserved_codex_provider_id(id: &str) -> bool {
|
||||
matches!(
|
||||
id.trim().to_ascii_lowercase().as_str(),
|
||||
"openai" | "amazon-bedrock" | "ollama" | "lmstudio" | "oss"
|
||||
)
|
||||
}
|
||||
|
||||
fn custom_provider_id(input: &str) -> String {
|
||||
let id = sanitize_id(input);
|
||||
if reserved_codex_provider_id(&id) {
|
||||
format!("{id}-custom")
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
fn experimental_bearer_token_from_doc(
|
||||
doc: &DocumentMut,
|
||||
provider_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let token_from_table = provider_id.and_then(|id| {
|
||||
doc.get("model_providers")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|providers| providers.get(id))
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| table.get("experimental_bearer_token"))
|
||||
.and_then(|item| item.as_str())
|
||||
});
|
||||
|
||||
token_from_table
|
||||
.or_else(|| {
|
||||
doc.get("experimental_bearer_token")
|
||||
.and_then(|item| item.as_str())
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn extract_ccswitch_codex_provider(
|
||||
id: &str,
|
||||
name: &str,
|
||||
@@ -1945,6 +1994,8 @@ fn extract_ccswitch_codex_provider(
|
||||
let model = string_value(&doc, "model").unwrap_or_else(|| "gpt-5.5".to_string());
|
||||
let active_provider =
|
||||
string_value(&doc, "model_provider").unwrap_or_else(|| "custom".to_string());
|
||||
let api_key =
|
||||
api_key.or_else(|| experimental_bearer_token_from_doc(&doc, Some(&active_provider)));
|
||||
|
||||
let provider_table = doc
|
||||
.get("model_providers")
|
||||
@@ -1980,10 +2031,10 @@ fn extract_ccswitch_codex_provider(
|
||||
let requires_openai_auth = provider_table
|
||||
.and_then(|table| table.get("requires_openai_auth"))
|
||||
.and_then(|item| item.as_bool())
|
||||
.unwrap_or(true);
|
||||
.unwrap_or(false);
|
||||
|
||||
Some(SavedProvider {
|
||||
id: sanitize_id(id),
|
||||
id: custom_provider_id(id),
|
||||
provider_name,
|
||||
base_url,
|
||||
model,
|
||||
@@ -3449,6 +3500,26 @@ fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> {
|
||||
.ok_or_else(|| CodexxError::Config(format!("{key} 不是 TOML table")))
|
||||
}
|
||||
|
||||
fn set_provider_bearer_token(doc: &mut DocumentMut, token: &str) {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
return;
|
||||
}
|
||||
let provider_id = string_value(doc, "model_provider");
|
||||
if let Some(provider_id) = provider_id {
|
||||
if let Some(provider_table) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|providers| providers.get_mut(provider_id.as_str()))
|
||||
.and_then(|item| item.as_table_mut())
|
||||
{
|
||||
provider_table["experimental_bearer_token"] = value(token);
|
||||
return;
|
||||
}
|
||||
}
|
||||
doc["experimental_bearer_token"] = value(token);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_skills_mcp_state(config_dir: Option<String>) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || build_skills_mcp_state_inner(config_dir))
|
||||
@@ -3689,7 +3760,7 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
|
||||
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
let normalized = SavedProvider {
|
||||
id: provider.id.trim().to_string(),
|
||||
id: custom_provider_id(&provider.id),
|
||||
provider_name: provider.provider_name.trim().to_string(),
|
||||
base_url: provider.base_url.trim().trim_end_matches('/').to_string(),
|
||||
model: provider.model.trim().to_string(),
|
||||
@@ -3942,19 +4013,22 @@ fn save_provider_toml_config_inner(input: ProviderTomlInput) -> Result<ActionRes
|
||||
let backup_id = create_backup(&codex_dir, "save-provider-toml")?;
|
||||
|
||||
let config_text = input.config_text.trim_end().to_string();
|
||||
let doc = parse_toml_document(&cfg, &config_text)?;
|
||||
let mut doc = parse_toml_document(&cfg, &config_text)?;
|
||||
if string_value(&doc, "model").is_none() {
|
||||
return Err(CodexxError::Config(
|
||||
"config.toml 必须包含 model".to_string(),
|
||||
));
|
||||
}
|
||||
write_text(&cfg, &(config_text + "\n"))?;
|
||||
|
||||
if let Some(api_key) = input
|
||||
let api_key = input
|
||||
.api_key
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(api_key) = api_key.as_deref() {
|
||||
set_provider_bearer_token(&mut doc, api_key);
|
||||
}
|
||||
write_text(&cfg, &(doc.to_string().trim_end().to_string() + "\n"))?;
|
||||
|
||||
if let Some(api_key) = api_key {
|
||||
let mut auth_value = if auth.exists() {
|
||||
let text = fs::read_to_string(&auth).map_err(|e| io_err(&auth, e))?;
|
||||
serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({}))
|
||||
@@ -3965,6 +4039,7 @@ fn save_provider_toml_config_inner(input: ProviderTomlInput) -> Result<ActionRes
|
||||
auth_value = json!({});
|
||||
}
|
||||
auth_value["OPENAI_API_KEY"] = Value::String(api_key);
|
||||
auth_value["auth_mode"] = Value::String("api_key".to_string());
|
||||
write_json(&auth, &auth_value)?;
|
||||
}
|
||||
|
||||
@@ -3984,6 +4059,61 @@ async fn save_provider_toml_config(input: ProviderTomlInput) -> Result<ActionRes
|
||||
.map_err(|e| CodexxError::Config(format!("保存供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
fn test_provider_connection_inner(base_url: String) -> Result<ProviderConnectionResult> {
|
||||
let base = base_url.trim().trim_end_matches('/').to_string();
|
||||
if base.is_empty() {
|
||||
return Err(CodexxError::Config("base_url 不能为空".to_string()));
|
||||
}
|
||||
if !base.starts_with("http://") && !base.starts_with("https://") {
|
||||
return Err(CodexxError::Config(
|
||||
"base_url 必须以 http:// 或 https:// 开头".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(6))
|
||||
.build();
|
||||
let models_url = format!("{base}/models");
|
||||
let candidates = [models_url.as_str(), base.as_str()];
|
||||
let mut last_error = String::new();
|
||||
|
||||
for url in candidates {
|
||||
match agent.get(url).call() {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
return Ok(ProviderConnectionResult {
|
||||
ok: (200..500).contains(&status),
|
||||
status: Some(status),
|
||||
message: format!("{url} HTTP {status}"),
|
||||
});
|
||||
}
|
||||
Err(ureq::Error::Status(status, _)) => {
|
||||
return Ok(ProviderConnectionResult {
|
||||
ok: status < 500,
|
||||
status: Some(status),
|
||||
message: format!("{url} HTTP {status}"),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = format!("{url}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProviderConnectionResult {
|
||||
ok: false,
|
||||
status: None,
|
||||
message: last_error,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn test_provider_connection(base_url: String) -> Result<ProviderConnectionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || test_provider_connection_inner(base_url))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("测试连接失败: {e}")))?
|
||||
}
|
||||
|
||||
fn switch_provider_inner(input: ProviderInput) -> Result<ActionResult> {
|
||||
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
|
||||
fs::create_dir_all(&codex_dir).map_err(|e| io_err(&codex_dir, e))?;
|
||||
@@ -3992,6 +4122,13 @@ fn switch_provider_inner(input: ProviderInput) -> Result<ActionResult> {
|
||||
let backup_id = create_backup(&codex_dir, "switch-provider")?;
|
||||
|
||||
let provider_name = input.provider_name.trim();
|
||||
let provider_key = input
|
||||
.provider_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(custom_provider_id)
|
||||
.unwrap_or_else(|| custom_provider_id(provider_name));
|
||||
let base_url = input.base_url.trim().trim_end_matches('/');
|
||||
let model = input.model.trim();
|
||||
if provider_name.is_empty() {
|
||||
@@ -4006,25 +4143,29 @@ fn switch_provider_inner(input: ProviderInput) -> Result<ActionResult> {
|
||||
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
doc["model_provider"] = value("custom");
|
||||
doc["model_provider"] = value(provider_key.as_str());
|
||||
doc["model"] = value(model);
|
||||
set_top_level_defaults(&mut doc);
|
||||
|
||||
let root = doc.as_table_mut();
|
||||
let providers = ensure_table(root, "model_providers")?;
|
||||
let custom = ensure_table(providers, "custom")?;
|
||||
custom["name"] = value(provider_name);
|
||||
custom["base_url"] = value(base_url);
|
||||
custom["wire_api"] = value(input.wire_api.unwrap_or_else(|| "responses".to_string()));
|
||||
custom["requires_openai_auth"] = value(input.requires_openai_auth.unwrap_or(true));
|
||||
let provider_table = ensure_table(providers, provider_key.as_str())?;
|
||||
provider_table["name"] = value(provider_name);
|
||||
provider_table["base_url"] = value(base_url);
|
||||
provider_table["wire_api"] = value(input.wire_api.unwrap_or_else(|| "responses".to_string()));
|
||||
provider_table["requires_openai_auth"] = value(input.requires_openai_auth.unwrap_or(false));
|
||||
|
||||
let api_key = input
|
||||
.api_key
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(api_key) = api_key.as_deref() {
|
||||
provider_table["experimental_bearer_token"] = value(api_key);
|
||||
}
|
||||
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
if let Some(api_key) = input
|
||||
.api_key
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
if let Some(api_key) = api_key {
|
||||
let mut auth_value = if auth.exists() {
|
||||
let text = fs::read_to_string(&auth).map_err(|e| io_err(&auth, e))?;
|
||||
serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({}))
|
||||
@@ -4035,6 +4176,7 @@ fn switch_provider_inner(input: ProviderInput) -> Result<ActionResult> {
|
||||
auth_value = json!({});
|
||||
}
|
||||
auth_value["OPENAI_API_KEY"] = Value::String(api_key);
|
||||
auth_value["auth_mode"] = Value::String("api_key".to_string());
|
||||
write_json(&auth, &auth_value)?;
|
||||
}
|
||||
|
||||
@@ -4174,6 +4316,7 @@ pub fn run() {
|
||||
disable_instruction,
|
||||
switch_provider,
|
||||
save_provider_toml_config,
|
||||
test_provider_connection,
|
||||
list_backups,
|
||||
restore_backup,
|
||||
open_url,
|
||||
@@ -4181,3 +4324,134 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Codex-X");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_codex_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("create temp codex dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_provider_writes_real_provider_key_and_api_key_auth_mode() {
|
||||
let codex_dir = temp_codex_dir("switch-provider");
|
||||
let result = switch_provider_inner(ProviderInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
provider_id: Some("magicai".to_string()),
|
||||
provider_name: "MagicAI".to_string(),
|
||||
base_url: "https://example.com/v1/".to_string(),
|
||||
model: "gpt-5.5".to_string(),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
wire_api: Some("responses".to_string()),
|
||||
requires_openai_auth: None,
|
||||
})
|
||||
.expect("switch provider");
|
||||
|
||||
assert_eq!(result.state.model_provider.as_deref(), Some("magicai"));
|
||||
assert_eq!(result.state.model.as_deref(), Some("gpt-5.5"));
|
||||
|
||||
let config_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
|
||||
assert!(config_text.contains("model_provider = \"magicai\""));
|
||||
assert!(config_text.contains("[model_providers.magicai]"));
|
||||
assert!(config_text.contains("requires_openai_auth = false"));
|
||||
assert!(config_text.contains("experimental_bearer_token = \"sk-test\""));
|
||||
|
||||
let auth_text = fs::read_to_string(auth_path(&codex_dir)).expect("read auth");
|
||||
let auth: Value = serde_json::from_str(&auth_text).expect("parse auth");
|
||||
assert_eq!(
|
||||
auth.get("OPENAI_API_KEY").and_then(Value::as_str),
|
||||
Some("sk-test")
|
||||
);
|
||||
assert_eq!(
|
||||
auth.get("auth_mode").and_then(Value::as_str),
|
||||
Some("api_key")
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_provider_avoids_reserved_builtin_provider_ids() {
|
||||
let codex_dir = temp_codex_dir("switch-provider-reserved");
|
||||
let result = switch_provider_inner(ProviderInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
provider_name: "OpenAI".to_string(),
|
||||
base_url: "https://proxy.example.com/v1".to_string(),
|
||||
model: "gpt-5.5".to_string(),
|
||||
api_key: Some("sk-proxy".to_string()),
|
||||
wire_api: Some("responses".to_string()),
|
||||
requires_openai_auth: None,
|
||||
})
|
||||
.expect("switch provider");
|
||||
|
||||
assert_eq!(
|
||||
result.state.model_provider.as_deref(),
|
||||
Some("openai-custom")
|
||||
);
|
||||
let config_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
|
||||
assert!(config_text.contains("model_provider = \"openai-custom\""));
|
||||
assert!(config_text.contains("[model_providers.openai-custom]"));
|
||||
assert!(!config_text.contains("[model_providers.openai]"));
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_ccswitch_provider_reads_experimental_bearer_token() {
|
||||
let settings_config = json!({
|
||||
"auth": {},
|
||||
"config": r#"model_provider = "custom"
|
||||
model = "gpt-5.5"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Proxy"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-from-config"
|
||||
"#,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let provider =
|
||||
extract_ccswitch_codex_provider("openai", "Proxy", &settings_config).expect("provider");
|
||||
assert_eq!(provider.id, "openai-custom");
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-from-config"));
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_provider_toml_config_writes_provider_scoped_bearer_token() {
|
||||
let codex_dir = temp_codex_dir("save-provider-toml-token");
|
||||
let result = save_provider_toml_config_inner(ProviderTomlInput {
|
||||
config_dir: Some(codex_dir.display().to_string()),
|
||||
config_text: r#"model_provider = "proxy"
|
||||
model = "gpt-5.5"
|
||||
|
||||
[model_providers.proxy]
|
||||
name = "Proxy"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
"#
|
||||
.to_string(),
|
||||
api_key: Some("sk-provider-table".to_string()),
|
||||
})
|
||||
.expect("save provider toml");
|
||||
|
||||
assert!(result.ok);
|
||||
let config_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
|
||||
assert!(config_text.contains("[model_providers.proxy]"));
|
||||
assert!(config_text.contains("experimental_bearer_token = \"sk-provider-table\""));
|
||||
|
||||
let _ = fs::remove_dir_all(codex_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.2.25",
|
||||
"version": "0.2.27",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+107
-58
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
KeyRound,
|
||||
Layers3,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Power,
|
||||
RefreshCw,
|
||||
@@ -146,6 +148,12 @@ type ReleaseInfo = {
|
||||
hasUpdate?: boolean;
|
||||
};
|
||||
|
||||
type ProviderConnectionResult = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
|
||||
type SessionSyncStatus = {
|
||||
codexDir: string;
|
||||
@@ -279,7 +287,7 @@ const defaultProviderForm: SavedProvider = {
|
||||
model: "gpt-5.5",
|
||||
apiKey: "",
|
||||
wireApi: "responses",
|
||||
requiresOpenaiAuth: true,
|
||||
requiresOpenaiAuth: false,
|
||||
};
|
||||
|
||||
const blankProviderForm: SavedProvider = {
|
||||
@@ -289,7 +297,7 @@ const blankProviderForm: SavedProvider = {
|
||||
model: "gpt-5.5",
|
||||
apiKey: "",
|
||||
wireApi: "responses",
|
||||
requiresOpenaiAuth: true,
|
||||
requiresOpenaiAuth: false,
|
||||
};
|
||||
|
||||
const blankPromptForm: SavedPrompt = {
|
||||
@@ -531,6 +539,15 @@ function providerId(name: string) {
|
||||
return slug || `provider-${Date.now()}`;
|
||||
}
|
||||
|
||||
function isReservedCodexProviderId(id: string) {
|
||||
return ["openai", "amazon-bedrock", "ollama", "lmstudio", "oss"].includes(id.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function customProviderId(name: string) {
|
||||
const id = providerId(name);
|
||||
return isReservedCodexProviderId(id) ? `${id}-custom` : id;
|
||||
}
|
||||
|
||||
function StatusPill({ active, label }: { active: boolean; label: string }) {
|
||||
return <span className={cx("pill", active ? "pill-ok" : "pill-muted")}>{label}</span>;
|
||||
}
|
||||
@@ -599,8 +616,10 @@ function extractOpenAiApiKey(authText?: string) {
|
||||
function buildProviderTomlPreview(provider: SavedProvider, state: CodexState | null) {
|
||||
const model = provider.model.trim() || "gpt-5.5";
|
||||
const name = provider.providerName.trim() || "your-provider";
|
||||
const providerKey = customProviderId(provider.id || name || provider.baseUrl || "custom");
|
||||
const baseUrl = provider.baseUrl.trim().replace(/\/+$/, "") || "https://example.com/v1";
|
||||
const wireApi = provider.wireApi || "responses";
|
||||
const apiKey = provider.apiKey?.trim();
|
||||
const source = state?.configText?.trimEnd() || "";
|
||||
const sourceLines = source ? source.split("\n") : [];
|
||||
const keptLines: string[] = [];
|
||||
@@ -612,7 +631,7 @@ function buildProviderTomlPreview(provider: SavedProvider, state: CodexState | n
|
||||
const sectionMatch = line.match(/^\s*\[([^\]]+)]\s*$/);
|
||||
if (sectionMatch) {
|
||||
currentSection = sectionMatch[1].trim();
|
||||
skippingCustomProvider = currentSection === "model_providers.custom";
|
||||
skippingCustomProvider = currentSection === `model_providers.${providerKey}`;
|
||||
if (skippingCustomProvider) continue;
|
||||
}
|
||||
if (skippingCustomProvider) continue;
|
||||
@@ -637,7 +656,7 @@ function buildProviderTomlPreview(provider: SavedProvider, state: CodexState | n
|
||||
});
|
||||
|
||||
const headerLines = [
|
||||
'model_provider = "custom"',
|
||||
`model_provider = "${tomlEscape(providerKey)}"`,
|
||||
`model = "${tomlEscape(model)}"`,
|
||||
];
|
||||
if (!hasReasoningEffort) {
|
||||
@@ -645,11 +664,12 @@ function buildProviderTomlPreview(provider: SavedProvider, state: CodexState | n
|
||||
}
|
||||
|
||||
const providerLines = [
|
||||
"[model_providers.custom]",
|
||||
`[model_providers.${providerKey}]`,
|
||||
`name = "${tomlEscape(name)}"`,
|
||||
`base_url = "${tomlEscape(baseUrl)}"`,
|
||||
`wire_api = "${tomlEscape(wireApi)}"`,
|
||||
`requires_openai_auth = ${provider.requiresOpenaiAuth ? "true" : "false"}`,
|
||||
...(apiKey ? [`experimental_bearer_token = "${tomlEscape(apiKey)}"`] : []),
|
||||
];
|
||||
|
||||
return [
|
||||
@@ -664,7 +684,7 @@ function buildProviderTomlPreview(provider: SavedProvider, state: CodexState | n
|
||||
|
||||
function buildProviderAuthPreview(provider: SavedProvider) {
|
||||
const key = provider.apiKey?.trim();
|
||||
return JSON.stringify({ OPENAI_API_KEY: key || null }, null, 2);
|
||||
return JSON.stringify({ OPENAI_API_KEY: key || null, auth_mode: key ? "api_key" : undefined }, null, 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -840,6 +860,7 @@ function App() {
|
||||
const [providerTomlDraft, setProviderTomlDraft] = React.useState("");
|
||||
const [providerTomlDirty, setProviderTomlDirty] = React.useState(false);
|
||||
const [providerApiKeyVisible, setProviderApiKeyVisible] = React.useState(false);
|
||||
const [providerTestingId, setProviderTestingId] = React.useState("");
|
||||
const [actionBusy, setActionBusy] = React.useState<string>("");
|
||||
const [promptForm, setPromptForm] = React.useState<SavedPrompt>(blankPromptForm);
|
||||
const [officialForm, setOfficialForm] = React.useState({ model: "gpt-5.5", authJson: "" });
|
||||
@@ -910,7 +931,7 @@ function App() {
|
||||
baseUrl: p.baseUrl || "",
|
||||
model: state?.model || "gpt-5.5",
|
||||
wireApi: p.wireApi || "responses",
|
||||
requiresOpenaiAuth: p.requiresOpenaiAuth ?? true,
|
||||
requiresOpenaiAuth: p.requiresOpenaiAuth ?? false,
|
||||
isCurrent: p.isCurrent,
|
||||
}));
|
||||
}, [state]);
|
||||
@@ -921,7 +942,7 @@ function App() {
|
||||
source: "local" as const,
|
||||
isCurrent:
|
||||
Boolean(currentProvider) &&
|
||||
currentProvider?.baseUrl === p.baseUrl &&
|
||||
(currentProvider?.id === p.id || currentProvider?.baseUrl === p.baseUrl) &&
|
||||
(state?.model || "") === p.model,
|
||||
}));
|
||||
}, [savedProviders, currentProvider, state?.model]);
|
||||
@@ -1009,28 +1030,33 @@ function App() {
|
||||
const refresh = React.useCallback(() => {
|
||||
call(
|
||||
async () => {
|
||||
const [diagnostics, next, backupList, providerList, promptList, builtinStatus, about] = await Promise.all([
|
||||
invoke<StartupDiagnostics>("get_startup_diagnostics", { configDir: configDir || null }),
|
||||
const [next, providerList, promptList, builtinStatus, about] = await Promise.all([
|
||||
invoke<CodexState>("get_codex_state", { configDir: configDir || null }),
|
||||
invoke<BackupEntry[]>("list_backups"),
|
||||
invoke<SavedProvider[]>("list_saved_providers"),
|
||||
invoke<SavedPrompt[]>("list_saved_prompts"),
|
||||
invoke<BuiltinPromptStatus[]>("get_builtin_prompt_status"),
|
||||
invoke<AboutInfo>("get_about_info", { configDir: configDir || null }),
|
||||
]);
|
||||
return { diagnostics, next, backupList, providerList, promptList, builtinStatus, about };
|
||||
return { next, providerList, promptList, builtinStatus, about };
|
||||
},
|
||||
({ diagnostics, next, backupList, providerList, promptList, builtinStatus, about }) => {
|
||||
setStartupDiagnostics(diagnostics);
|
||||
({ next, providerList, promptList, builtinStatus, about }) => {
|
||||
setState(next);
|
||||
setBackups(backupList);
|
||||
setSavedProviders(providerList);
|
||||
setSavedPrompts(promptList);
|
||||
setBuiltinPromptStatus(builtinStatus);
|
||||
setAboutInfo(about);
|
||||
if (!configDir) setConfigDir(next.codexDir);
|
||||
void invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || next.codexDir || null, targetProvider: null })
|
||||
.then(setSessionStatus)
|
||||
const resolvedConfigDir = configDir || next.codexDir || null;
|
||||
void Promise.all([
|
||||
invoke<StartupDiagnostics>("get_startup_diagnostics", { configDir: resolvedConfigDir }),
|
||||
invoke<BackupEntry[]>("list_backups"),
|
||||
invoke<SessionSyncStatus>("get_session_sync_status", { configDir: resolvedConfigDir, targetProvider: null }),
|
||||
])
|
||||
.then(([diagnostics, backupList, sessions]) => {
|
||||
setStartupDiagnostics(diagnostics);
|
||||
setBackups(backupList);
|
||||
setSessionStatus(sessions);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
);
|
||||
@@ -1044,13 +1070,18 @@ function App() {
|
||||
const handleActionResult = (result: ActionResult) => {
|
||||
setState(result.state);
|
||||
setToast(result.message);
|
||||
const resolvedConfigDir = configDir || result.state.codexDir || null;
|
||||
void Promise.all([
|
||||
invoke<BackupEntry[]>("list_backups"),
|
||||
invoke<SavedPrompt[]>("list_saved_prompts"),
|
||||
invoke<SavedProvider[]>("list_saved_providers"),
|
||||
invoke<SessionSyncStatus>("get_session_sync_status", { configDir: resolvedConfigDir, targetProvider: null }),
|
||||
])
|
||||
.then(([backupList, promptList]) => {
|
||||
.then(([backupList, promptList, providerList, sessions]) => {
|
||||
setBackups(backupList);
|
||||
setSavedPrompts(promptList);
|
||||
setSavedProviders(providerList);
|
||||
setSessionStatus(sessions);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
@@ -1191,7 +1222,7 @@ function App() {
|
||||
|
||||
const normalizedProviderForm = (): SavedProvider => ({
|
||||
...providerForm,
|
||||
id: editingProviderId || providerForm.id || providerId(providerForm.providerName || providerForm.baseUrl),
|
||||
id: editingProviderId || providerForm.id || customProviderId(providerForm.providerName || providerForm.baseUrl),
|
||||
providerName: providerForm.providerName.trim(),
|
||||
baseUrl: providerForm.baseUrl.trim().replace(/\/+$/, ""),
|
||||
model: providerForm.model.trim(),
|
||||
@@ -1227,6 +1258,7 @@ function App() {
|
||||
invoke<ActionResult>("switch_provider", {
|
||||
input: {
|
||||
configDir: configDir || null,
|
||||
providerId: provider.id,
|
||||
providerName: provider.providerName,
|
||||
baseUrl: provider.baseUrl,
|
||||
model: provider.model,
|
||||
@@ -1238,6 +1270,21 @@ function App() {
|
||||
handleActionResult,
|
||||
);
|
||||
|
||||
const testProvider = async (id: string, baseUrl: string) => {
|
||||
setProviderTestingId(id);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<ProviderConnectionResult>("test_provider_connection", { baseUrl });
|
||||
setToast(result.ok
|
||||
? (lang === "zh" ? `连接正常:${result.message}` : `Connection OK: ${result.message}`)
|
||||
: (lang === "zh" ? `连接失败:${result.message}` : `Connection failed: ${result.message}`));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setProviderTestingId("");
|
||||
}
|
||||
};
|
||||
|
||||
const saveAndSwitch = () =>
|
||||
call(
|
||||
async () => {
|
||||
@@ -1526,9 +1573,9 @@ function App() {
|
||||
};
|
||||
|
||||
const openEditDetectedProvider = (provider: { id: string; providerName: string; baseUrl: string; model: string; wireApi: string; requiresOpenaiAuth: boolean }) => {
|
||||
setEditingProviderId(providerId(provider.providerName || provider.baseUrl));
|
||||
setEditingProviderId(customProviderId(provider.providerName || provider.baseUrl));
|
||||
const next = {
|
||||
id: providerId(provider.providerName || provider.baseUrl),
|
||||
id: customProviderId(provider.providerName || provider.baseUrl),
|
||||
providerName: provider.providerName,
|
||||
baseUrl: provider.baseUrl,
|
||||
model: provider.model,
|
||||
@@ -1854,7 +1901,7 @@ function App() {
|
||||
: item.baseUrl === p.baseUrl && item.model === p.model,
|
||||
);
|
||||
const switchable: SavedProvider | null = p.source === "official" ? null : local || {
|
||||
id: providerId(p.providerName),
|
||||
id: customProviderId(p.providerName),
|
||||
providerName: p.providerName,
|
||||
baseUrl: p.baseUrl,
|
||||
model: p.model,
|
||||
@@ -1870,21 +1917,20 @@ function App() {
|
||||
<strong>{p.providerName}</strong>
|
||||
<a>{p.baseUrl || "no base_url"}</a>
|
||||
</div>
|
||||
<div className="provider-meta">
|
||||
<span>{p.source === "official" ? t.provider.official : p.source === "detected" ? t.provider.detected : t.provider.local}</span>
|
||||
<code>{p.model}</code>
|
||||
</div>
|
||||
<div className="provider-badges">
|
||||
{p.isCurrent && <StatusPill active label={t.provider.current} />}
|
||||
{p.source === "official" && <StatusPill active={false} label={t.provider.noRouting} />}
|
||||
{p.source === "official" && <StatusPill active={state.officialAuthAvailable} label={state.officialAuthAvailable ? t.provider.authReady : t.provider.authMissing} />}
|
||||
</div>
|
||||
<div className="provider-actions">
|
||||
<button className="secondary-btn small" onClick={() => switchable ? switchProvider(switchable) : switchOfficialProvider()} disabled={loading || p.isCurrent}>{t.provider.switch}</button>
|
||||
{p.source === "official" && <button className="ghost-btn small" onClick={openOfficialEdit}>{t.provider.viewEdit}</button>}
|
||||
{local && <button className="ghost-btn small" onClick={() => openEditProvider(local)}>{t.provider.edit}</button>}
|
||||
{!local && p.source === "detected" && <button className="ghost-btn small" onClick={() => openEditDetectedProvider(p)}>{t.provider.edit}</button>}
|
||||
{local && <button className="danger-btn small" onClick={() => removeProvider(local.id)}><Trash2 size={14} /> {t.provider.remove}</button>}
|
||||
<button className="secondary-btn small" onClick={() => switchable ? switchProvider(switchable) : switchOfficialProvider()} disabled={loading || p.isCurrent}>{lang === "zh" ? "启用" : "Enable"}</button>
|
||||
{p.source !== "official" && (
|
||||
<button className="icon-btn small" title={lang === "zh" ? "测试连接" : "Test connection"} onClick={() => void testProvider(`${p.source}-${p.id}`, p.baseUrl)} disabled={loading || providerTestingId === `${p.source}-${p.id}`}>
|
||||
{providerTestingId === `${p.source}-${p.id}` ? <Loader2 size={15} className="spin" /> : <Activity size={15} />}
|
||||
</button>
|
||||
)}
|
||||
{p.source === "official" && <button className="icon-btn small" title={t.provider.edit} onClick={openOfficialEdit}><PencilLine size={15} /></button>}
|
||||
{local && <button className="icon-btn small" title={t.provider.edit} onClick={() => openEditProvider(local)}><PencilLine size={15} /></button>}
|
||||
{!local && p.source === "detected" && <button className="icon-btn small" title={t.provider.edit} onClick={() => openEditDetectedProvider(p)}><PencilLine size={15} /></button>}
|
||||
{local && <button className="icon-btn danger small" title={t.provider.remove} onClick={() => removeProvider(local.id)}><Trash2 size={15} /></button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1957,7 +2003,7 @@ function App() {
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={lang === "zh" ? "API 请求地址" : t.provider.baseUrl}><input value={providerForm.baseUrl} onChange={(e) => setProviderForm({ ...providerForm, baseUrl: e.target.value })} /></Field>
|
||||
<Field label={t.provider.name}><input value={providerForm.providerName} onChange={(e) => setProviderForm({ ...providerForm, providerName: e.target.value, id: editingProviderId || providerId(e.target.value) })} /></Field>
|
||||
<Field label={t.provider.name}><input value={providerForm.providerName} onChange={(e) => setProviderForm({ ...providerForm, providerName: e.target.value, id: editingProviderId || customProviderId(e.target.value) })} /></Field>
|
||||
<Field label={t.provider.model}><input value={providerForm.model} onChange={(e) => setProviderForm({ ...providerForm, model: e.target.value })} /></Field>
|
||||
<Field label={t.provider.wireApi}>
|
||||
<select value={providerForm.wireApi} onChange={(e) => setProviderForm({ ...providerForm, wireApi: e.target.value })}>
|
||||
@@ -2290,7 +2336,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="instruction-row-list">
|
||||
<div className="skills-mcp-simple-list instruction-switch-list">
|
||||
{instructionTemplates.map((item) => {
|
||||
const isCurrent = currentInstructionId === item.id;
|
||||
const remoteStatus = builtinPromptStatusMap.get(item.id);
|
||||
@@ -2300,7 +2346,7 @@ function App() {
|
||||
? (lang === "zh" ? "本地缓存" : "Local cache")
|
||||
: (lang === "zh" ? "打包内置" : "Bundled");
|
||||
return (
|
||||
<div className={cx("instruction-row", isCurrent && "selected")} key={item.id}>
|
||||
<article className={cx("skills-mcp-simple-row instruction-switch-row", isCurrent && "selected")} key={item.id}>
|
||||
<div className="instruction-main">
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.subtitle}</p>
|
||||
@@ -2309,47 +2355,50 @@ function App() {
|
||||
{remoteStatus?.checkedAt && <small>{new Date(remoteStatus.checkedAt).toLocaleString()}</small>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="instruction-status-col">
|
||||
{isCurrent ? <StatusPill active label={t.provider.current} /> : <span />}
|
||||
</div>
|
||||
<div className="instruction-action-col">
|
||||
<button className="secondary-btn small lively-btn" onClick={() => switchInstructionTemplate(item.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
|
||||
<button className="ghost-btn small" onClick={disableInstruction} disabled={loading || !isCurrent}>{lang === "zh" ? "禁用" : "Disable"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={cx("switch-toggle", isCurrent && "on")}
|
||||
title={isCurrent ? (lang === "zh" ? "关闭" : "Disable") : (lang === "zh" ? "启用" : "Enable")}
|
||||
onClick={() => isCurrent ? disableInstruction() : switchInstructionTemplate(item.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{savedPrompts.map((prompt) => {
|
||||
const isCurrent = Boolean(state.instructionFile) && (state.instructionFile || "").replace(/\\/g, "/").endsWith(prompt.filename);
|
||||
return (
|
||||
<div className={cx("instruction-row", isCurrent && "selected")} key={prompt.id}>
|
||||
<article className={cx("skills-mcp-simple-row instruction-switch-row", isCurrent && "selected")} key={prompt.id}>
|
||||
<div className="instruction-main">
|
||||
<strong>{prompt.title}</strong>
|
||||
<p>{lang === "zh" ? "自定义指令提示词" : "Custom instruction prompt"}</p>
|
||||
</div>
|
||||
<div className="instruction-status-col">
|
||||
{isCurrent ? <StatusPill active label={t.provider.current} /> : <span />}
|
||||
<div className="instruction-icon-actions">
|
||||
<button
|
||||
className={cx("switch-toggle", isCurrent && "on")}
|
||||
title={isCurrent ? (lang === "zh" ? "关闭" : "Disable") : (lang === "zh" ? "启用" : "Enable")}
|
||||
onClick={() => isCurrent ? disableInstruction() : enableSavedPrompt(prompt.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
<button className="icon-btn small" title={t.provider.edit} onClick={() => openEditPrompt(prompt)}><PencilLine size={15} /></button>
|
||||
<button className="icon-btn danger small" title={t.provider.remove} onClick={() => removeSavedPrompt(prompt.id)}><Trash2 size={15} /></button>
|
||||
</div>
|
||||
<div className="instruction-action-col">
|
||||
<button className="secondary-btn small lively-btn" onClick={() => enableSavedPrompt(prompt.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
|
||||
<button className="ghost-btn small" onClick={disableInstruction} disabled={loading || !isCurrent}>{lang === "zh" ? "禁用" : "Disable"}</button>
|
||||
<button className="ghost-btn small" onClick={() => openEditPrompt(prompt)}>{t.provider.edit}</button>
|
||||
<button className="danger-btn small" onClick={() => removeSavedPrompt(prompt.id)}><Trash2 size={14} /> {t.provider.remove}</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{state.instructionFile && currentInstructionId === "custom" && !savedPrompts.some((p) => state.instructionFile?.replace(/\\/g, "/").endsWith(p.filename)) && (
|
||||
<div className="instruction-row selected">
|
||||
<article className="skills-mcp-simple-row instruction-switch-row selected">
|
||||
<div className="instruction-main">
|
||||
<strong>{lang === "zh" ? "当前自定义指令提示词" : "Current custom prompt"}</strong>
|
||||
<p>{lang === "zh" ? "当前使用的是外部提示词;切换到内置模板前会自动保存到下方列表,之后仍可重新启用。" : "An external prompt is currently active. It will be saved before switching to a built-in template so you can enable it again later."}</p>
|
||||
</div>
|
||||
<div className="instruction-status-col"><StatusPill active label={t.provider.current} /></div>
|
||||
<div className="instruction-action-col"><button className="ghost-btn small" onClick={disableInstruction} disabled={loading}>{lang === "zh" ? "禁用" : "Disable"}</button></div>
|
||||
</div>
|
||||
<button className="switch-toggle on" title={lang === "zh" ? "关闭" : "Disable"} onClick={disableInstruction} disabled={loading}><span /></button>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -2316,6 +2316,91 @@ html, body, #root {
|
||||
.switch-toggle:disabled {
|
||||
opacity: .72;
|
||||
}
|
||||
.switch-toggle:hover:not(:disabled) {
|
||||
border-color: rgba(33,236,158,.42);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,.11);
|
||||
background: rgba(255,255,255,.055);
|
||||
color: #aeb8ca;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background .16s ease, border-color .16s ease, color .16s ease, transform .16s ease;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
color: #f5f8ff;
|
||||
background: rgba(255,255,255,.085);
|
||||
border-color: rgba(124,168,255,.24);
|
||||
}
|
||||
.icon-btn.danger {
|
||||
color: #ff9fb5;
|
||||
background: rgba(255, 80, 120, .075);
|
||||
border-color: rgba(255, 120, 160, .20);
|
||||
}
|
||||
.icon-btn.small {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Final compact provider list: name + URL + current pill + enable/icons. */
|
||||
.provider-row {
|
||||
grid-template-columns: 22px 46px minmax(280px, 1fr) minmax(76px, auto) max-content;
|
||||
}
|
||||
.provider-meta {
|
||||
display: none;
|
||||
}
|
||||
.provider-badges {
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.provider-actions {
|
||||
min-width: max-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.provider-actions .secondary-btn.small {
|
||||
min-width: 58px;
|
||||
}
|
||||
|
||||
/* Prompt templates use the same compact switch-list language as Skills/MCP. */
|
||||
.instruction-switch-list {
|
||||
gap: 8px;
|
||||
}
|
||||
.instruction-switch-row {
|
||||
min-height: 62px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 11px 10px 11px 14px;
|
||||
}
|
||||
.instruction-switch-row.selected {
|
||||
border-color: rgba(33,236,158,.36);
|
||||
background: rgba(18, 72, 53, .18);
|
||||
}
|
||||
.instruction-switch-row .instruction-main strong {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.instruction-switch-row .instruction-main p {
|
||||
margin: 0;
|
||||
color: #9aa8bf;
|
||||
font-size: 13px;
|
||||
}
|
||||
.instruction-switch-row .prompt-remote-meta {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.instruction-icon-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.skills-loading { min-height: 220px; }
|
||||
.session-empty.compact { min-height: 74px; }
|
||||
@media (max-width: 1280px) {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.2.25",
|
||||
"version": "0.2.27",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Reference in New Issue
Block a user