mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-19 06:09:33 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbd7bf351 |
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [v0.2.19] - 2026-07-06
|
||||
|
||||
- 新增【技能和 MCP】页面:展示 Codex 当前已安装 Skills 与 MCP,支持导入已有、从 ZIP 安装 Skill、启用/禁用 Skill、启用/禁用 MCP。
|
||||
- MCP 管理会读写 `~/.codex/config.toml` 的 `[mcp_servers]`,禁用后保留到 Codex-X SQLite,后续可一键重新启用。
|
||||
- Skills 管理会扫描 `~/.codex/skills`,并可从 `~/.agents/skills`、`~/.cc-switch/skills` 导入到 Codex;禁用后移动到 Codex-X 禁用目录,避免直接删除。
|
||||
- 优化多个潜在卡顿点:指令提示词 GitHub 检查改为延迟后台执行,远程拉取、会话扫描/同步、Skills/MCP 扫描均放入后台 blocking worker。
|
||||
- 修复外部自定义提示词切换到内置提示词后重复出现的问题,并自动清理同名 `external-*` 重复项。
|
||||
- 增加页面切换过渡、启动页动态光效和首次启动向导退出动画,降低页面突然跳转感。
|
||||
|
||||
## [v0.2.18] - 2026-07-05
|
||||
|
||||
- 指令提示词内置模板支持从 GitHub `examples/` 实时检查更新,并缓存到本地;启用内置模板时优先使用 GitHub 最新版本,离线时自动回退本地缓存或打包内置版本。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+52
-1
@@ -59,6 +59,15 @@ version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -337,18 +346,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.2.18"
|
||||
version = "0.2.19"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"thiserror 2.0.18",
|
||||
"toml_edit 0.22.27",
|
||||
"ureq",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -547,6 +558,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -4712,8 +4734,37 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.2.18"
|
||||
version = "0.2.19"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -24,3 +24,5 @@ dirs = "5.0"
|
||||
thiserror = "2.0"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
ureq = { version = "2.12", features = ["tls"] }
|
||||
sha2 = "0.10"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use thiserror::Error;
|
||||
@@ -272,6 +272,54 @@ struct SessionSyncResult {
|
||||
backup_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManagedMcpServer {
|
||||
id: String,
|
||||
name: String,
|
||||
transport: String,
|
||||
enabled: bool,
|
||||
source: String,
|
||||
summary: String,
|
||||
command: Option<String>,
|
||||
url: Option<String>,
|
||||
config_json: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManagedSkill {
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
directory: String,
|
||||
enabled: bool,
|
||||
source: String,
|
||||
path: String,
|
||||
content_hash: Option<String>,
|
||||
update_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkillsMcpState {
|
||||
codex_dir: String,
|
||||
codex_skills_dir: String,
|
||||
disabled_skills_dir: String,
|
||||
skills: Vec<ManagedSkill>,
|
||||
mcp_servers: Vec<ManagedMcpServer>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkillsMcpActionResult {
|
||||
imported_skills: usize,
|
||||
imported_mcp: usize,
|
||||
message: String,
|
||||
state: SkillsMcpState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RolloutScan {
|
||||
rollout_files: usize,
|
||||
@@ -367,9 +415,37 @@ fn open_db() -> Result<Connection> {
|
||||
content TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM prompts AS kept
|
||||
WHERE lower(kept.filename) = lower(prompts.filename)
|
||||
AND kept.id NOT LIKE 'external-%'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
@@ -485,7 +561,20 @@ fn list_saved_prompts_inner() -> Result<Vec<SavedPrompt>> {
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut prompts = Vec::new();
|
||||
for row in rows {
|
||||
prompts.push(row.map_err(|e| CodexxError::Database(e.to_string()))?);
|
||||
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let key = prompt.filename.to_ascii_lowercase();
|
||||
if let Some(index) = prompts
|
||||
.iter()
|
||||
.position(|existing: &SavedPrompt| existing.filename.to_ascii_lowercase() == key)
|
||||
{
|
||||
let existing_is_external = prompts[index].id.starts_with("external-");
|
||||
let prompt_is_external = prompt.id.starts_with("external-");
|
||||
if existing_is_external && !prompt_is_external {
|
||||
prompts[index] = prompt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
prompts.push(prompt);
|
||||
}
|
||||
Ok(prompts)
|
||||
}
|
||||
@@ -530,6 +619,30 @@ fn delete_prompt_inner(id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_saved_prompt_by_filename(filename: &str) -> Result<Option<SavedPrompt>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, title, filename, content FROM prompts
|
||||
WHERE lower(filename) = lower(?1)
|
||||
ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
match stmt.query_row([filename], |row| {
|
||||
Ok(SavedPrompt {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
})
|
||||
}) {
|
||||
Ok(prompt) => Ok(Some(prompt)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_prompt_meta(
|
||||
template_id: &str,
|
||||
) -> Result<(&'static str, &'static str, &'static str, &'static str)> {
|
||||
@@ -593,7 +706,7 @@ fn save_builtin_prompt_cache(
|
||||
|
||||
fn fetch_remote_prompt(source_url: &str) -> Result<String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build();
|
||||
let response = agent
|
||||
.get(source_url)
|
||||
@@ -753,17 +866,475 @@ fn remember_current_instruction_prompt(codex_dir: &Path) -> Result<Option<SavedP
|
||||
.file_stem()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("external-prompt");
|
||||
let id = format!("external-{}", sanitize_id(stem));
|
||||
let title = format!("外部提示词 · {stem}");
|
||||
let normalized_filename = normalize_prompt_filename(&file_name, "external-prompt");
|
||||
let existing = find_saved_prompt_by_filename(&normalized_filename)?;
|
||||
let (id, title) = existing
|
||||
.map(|prompt| (prompt.id, prompt.title))
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
format!("external-{}", sanitize_id(stem)),
|
||||
format!("外部提示词 · {stem}"),
|
||||
)
|
||||
});
|
||||
save_prompt_inner(SavedPrompt {
|
||||
id,
|
||||
title,
|
||||
filename: normalize_prompt_filename(&file_name, "external-prompt"),
|
||||
filename: normalized_filename,
|
||||
content,
|
||||
})
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
|
||||
fn codex_skills_dir(codex_dir: &Path) -> PathBuf {
|
||||
codex_dir.join("skills")
|
||||
}
|
||||
|
||||
fn disabled_skills_dir() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("disabled-skills"))
|
||||
}
|
||||
|
||||
fn sanitize_dir_name(input: &str, fallback: &str) -> String {
|
||||
let raw = input.trim().trim_matches('/').trim_matches('\\');
|
||||
let base = if raw.is_empty() { fallback } else { raw };
|
||||
let mut out = String::new();
|
||||
let mut last_dash = false;
|
||||
for ch in base.to_ascii_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
|
||||
out.push(ch);
|
||||
last_dash = false;
|
||||
} else if !last_dash {
|
||||
out.push('-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-');
|
||||
if out.is_empty() { fallback.to_string() } else { out.to_string() }
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dst).map_err(|e| io_err(dst, e))?;
|
||||
for entry in fs::read_dir(src).map_err(|e| io_err(src, e))? {
|
||||
let entry = entry.map_err(|e| io_err(src, e))?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let target = dst.join(file_name);
|
||||
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
copy_dir_recursive(&path, &target)?;
|
||||
} else if meta.is_file() {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
|
||||
}
|
||||
fs::copy(&path, &target).map_err(|e| io_err(&target, e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_dir_hash(dir: &Path) -> Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
fn collect(base: &Path, current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
|
||||
let entry = entry.map_err(|e| io_err(current, e))?;
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') { continue; }
|
||||
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
|
||||
if meta.file_type().is_symlink() { continue; }
|
||||
if meta.is_dir() { collect(base, &path, out)?; }
|
||||
else if meta.is_file() { out.push(path); }
|
||||
}
|
||||
let _ = base;
|
||||
Ok(())
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
collect(dir, dir, &mut files)?;
|
||||
files.sort();
|
||||
let mut hasher = Sha256::new();
|
||||
for path in files {
|
||||
let rel = path.strip_prefix(dir).unwrap_or(&path).to_string_lossy().replace('\\', "/");
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
let bytes = fs::read(&path).map_err(|e| io_err(&path, e))?;
|
||||
hasher.update(&bytes);
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn read_skill_metadata(skill_dir: &Path, fallback: &str) -> (String, Option<String>) {
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
let text = read_to_string_if_exists(&skill_md).unwrap_or_default();
|
||||
let mut title = None;
|
||||
let mut desc = None;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() { continue; }
|
||||
if title.is_none() && trimmed.starts_with('#') {
|
||||
title = Some(trimmed.trim_start_matches('#').trim().to_string());
|
||||
continue;
|
||||
}
|
||||
if desc.is_none() && !trimmed.starts_with('#') && !trimmed.starts_with("---") {
|
||||
desc = Some(trimmed.trim_matches('`').to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
(title.filter(|s| !s.is_empty()).unwrap_or_else(|| fallback.to_string()), desc.filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
fn save_managed_skill(skill: &ManagedSkill) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
conn.execute(
|
||||
"INSERT INTO managed_skills (id, name, description, directory, source_path, content_hash, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
directory = excluded.directory,
|
||||
source_path = excluded.source_path,
|
||||
content_hash = excluded.content_hash,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.directory,
|
||||
skill.path,
|
||||
skill.content_hash,
|
||||
skill.enabled,
|
||||
now_rfc3339()
|
||||
],
|
||||
).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_skill_dir(base: &Path, enabled: bool, source: &str, out: &mut Vec<ManagedSkill>, seen: &mut HashSet<String>) -> Result<()> {
|
||||
if !base.exists() { return Ok(()); }
|
||||
for entry in fs::read_dir(base).map_err(|e| io_err(base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(base, e))?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() || !path.join("SKILL.md").is_file() { continue; }
|
||||
let directory = entry.file_name().to_string_lossy().to_string();
|
||||
let id = sanitize_dir_name(&directory, "skill");
|
||||
if seen.contains(&id) { continue; }
|
||||
let (name, description) = read_skill_metadata(&path, &directory);
|
||||
let hash = compute_dir_hash(&path).ok();
|
||||
out.push(ManagedSkill {
|
||||
id: id.clone(), name, description, directory, enabled,
|
||||
source: source.to_string(), path: path.display().to_string(),
|
||||
content_hash: hash, update_status: "未检查".to_string(),
|
||||
});
|
||||
seen.insert(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(s) = value.as_str() { return json!(s); }
|
||||
if let Some(i) = value.as_integer() { return json!(i); }
|
||||
if let Some(f) = value.as_float() { return json!(f); }
|
||||
if let Some(b) = value.as_bool() { return json!(b); }
|
||||
if let Some(arr) = value.as_array() {
|
||||
return Value::Array(arr.iter().map(toml_value_to_json).collect());
|
||||
}
|
||||
Value::String(value.to_string())
|
||||
}
|
||||
|
||||
fn toml_item_to_json(item: &Item) -> Value {
|
||||
if let Some(v) = item.as_value() { return toml_value_to_json(v); }
|
||||
if let Some(tbl) = item.as_table() {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for (k, v) in tbl.iter() {
|
||||
obj.insert(k.to_string(), toml_item_to_json(v));
|
||||
}
|
||||
return Value::Object(obj);
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
match value_json {
|
||||
Value::String(s) => value(s.clone()),
|
||||
Value::Bool(b) => value(*b),
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() { value(i) }
|
||||
else if let Some(f) = n.as_f64() { value(f) }
|
||||
else { value(n.to_string()) }
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let mut toml_arr = toml_edit::Array::default();
|
||||
for item in arr {
|
||||
match item {
|
||||
Value::String(s) => { toml_arr.push(s.as_str()); }
|
||||
Value::Bool(b) => { toml_arr.push(*b); }
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() { toml_arr.push(i); }
|
||||
else if let Some(f) = n.as_f64() { toml_arr.push(f); }
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
value(toml_arr)
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
let mut table = Table::new();
|
||||
for (k, v) in obj {
|
||||
table.insert(k, json_to_toml_item(v));
|
||||
}
|
||||
Item::Table(table)
|
||||
}
|
||||
Value::Null => value(""),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_summary(config: &Value) -> (String, Option<String>, Option<String>, String) {
|
||||
let transport = config.get("type").and_then(Value::as_str).unwrap_or_else(|| {
|
||||
if config.get("url").is_some() { "http" } else { "stdio" }
|
||||
}).to_string();
|
||||
let command = config.get("command").and_then(Value::as_str).map(ToString::to_string);
|
||||
let url = config.get("url").and_then(Value::as_str).map(ToString::to_string);
|
||||
let args = config.get("args").and_then(Value::as_array).map(|arr| {
|
||||
arr.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")
|
||||
}).unwrap_or_default();
|
||||
let summary = if let Some(cmd) = &command {
|
||||
if args.is_empty() { cmd.clone() } else { format!("{cmd} {args}") }
|
||||
} else if let Some(url) = &url { url.clone() } else { transport.clone() };
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
conn.execute(
|
||||
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
server_config = excluded.server_config,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = excluded.updated_at",
|
||||
params![id, name, serde_json::to_string(config).unwrap_or_default(), enabled, now_rfc3339()],
|
||||
).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let text: String = row.get(2)?;
|
||||
let config = serde_json::from_str(&text).unwrap_or(Value::Object(Default::default()));
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, config, row.get::<_, bool>(3)?))
|
||||
}).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows { out.push(row.map_err(|e| CodexxError::Database(e.to_string()))?); }
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpServer>> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
if text.trim().is_empty() { return Ok(vec![]); }
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let Some(mcp_item) = doc.get("mcp_servers") else { return Ok(vec![]); };
|
||||
let Some(mcp_tbl) = mcp_item.as_table() else { return Ok(vec![]); };
|
||||
let mut out = Vec::new();
|
||||
for (id, item) in mcp_tbl.iter() {
|
||||
if !item.is_table() { continue; }
|
||||
let config = toml_item_to_json(item);
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.to_string(), name: id.to_string(), transport, enabled: true,
|
||||
source: "config.toml".to_string(), summary, command, url, config_json: config,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let disabled_dir = disabled_skills_dir()?;
|
||||
let mut warnings = Vec::new();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
if let Err(e) = scan_skill_dir(&skills_dir, true, "Codex", &mut skills, &mut seen) { warnings.push(e.to_string()); }
|
||||
if let Err(e) = scan_skill_dir(&disabled_dir, false, "Codex-X 已禁用", &mut skills, &mut seen) { warnings.push(e.to_string()); }
|
||||
|
||||
let mut mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
let enabled_ids: HashSet<String> = mcp_servers.iter().map(|s| s.id.clone()).collect();
|
||||
for (id, name, config, enabled) in db_managed_mcp()? {
|
||||
if enabled_ids.contains(&id) { continue; }
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
mcp_servers.push(ManagedMcpServer { id, name, transport, enabled, source: "Codex-X".to_string(), summary, command, url, config_json: config });
|
||||
}
|
||||
mcp_servers.sort_by(|a, b| b.enabled.cmp(&a.enabled).then_with(|| a.name.cmp(&b.name)));
|
||||
skills.sort_by(|a, b| b.enabled.cmp(&a.enabled).then_with(|| a.name.cmp(&b.name)));
|
||||
Ok(SkillsMcpState {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
codex_skills_dir: skills_dir.display().to_string(),
|
||||
disabled_skills_dir: disabled_dir.display().to_string(),
|
||||
skills, mcp_servers, warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn import_existing_skills_mcp_inner(config_dir: Option<String>) -> Result<SkillsMcpActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
fs::create_dir_all(&skills_dir).map_err(|e| io_err(&skills_dir, e))?;
|
||||
let mut imported_skills = 0usize;
|
||||
let candidates = vec![
|
||||
home_dir()?.join(".agents").join("skills"),
|
||||
home_dir()?.join(".cc-switch").join("skills"),
|
||||
];
|
||||
for base in candidates {
|
||||
if !base.exists() { continue; }
|
||||
for entry in fs::read_dir(&base).map_err(|e| io_err(&base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&base, e))?;
|
||||
let src = entry.path();
|
||||
if !src.is_dir() || !src.join("SKILL.md").is_file() { continue; }
|
||||
let directory = sanitize_dir_name(&entry.file_name().to_string_lossy(), "skill");
|
||||
let dst = skills_dir.join(&directory);
|
||||
if !dst.exists() {
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported_mcp = 0usize;
|
||||
for server in list_mcp_from_config(&codex_dir)? {
|
||||
save_managed_mcp(&server.id, &server.name, &server.config_json, true)?;
|
||||
imported_mcp += 1;
|
||||
}
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills, imported_mcp,
|
||||
message: format!("已导入 {imported_skills} 个 Skills,纳管 {imported_mcp} 个 MCP"),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
fn toggle_codex_mcp_inner(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
fs::create_dir_all(&codex_dir).map_err(|e| io_err(&codex_dir, e))?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
if enabled {
|
||||
let db = db_managed_mcp()?.into_iter().find(|(sid, _, _, _)| sid == &id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
doc["mcp_servers"][&id] = json_to_toml_item(&db.2);
|
||||
save_managed_mcp(&id, &db.1, &db.2, true)?;
|
||||
} else {
|
||||
if let Some(item) = doc.get("mcp_servers").and_then(|m| m.as_table()).and_then(|tbl| tbl.get(&id)) {
|
||||
let config = toml_item_to_json(item);
|
||||
save_managed_mcp(&id, &id, &config, false)?;
|
||||
}
|
||||
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
|
||||
tbl.remove(&id);
|
||||
}
|
||||
}
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
fn toggle_codex_skill_inner(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let disabled_dir = disabled_skills_dir()?;
|
||||
fs::create_dir_all(&skills_dir).map_err(|e| io_err(&skills_dir, e))?;
|
||||
fs::create_dir_all(&disabled_dir).map_err(|e| io_err(&disabled_dir, e))?;
|
||||
let current_state = build_skills_mcp_state_inner(config_dir.clone())?;
|
||||
let name = current_state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == id)
|
||||
.map(|skill| skill.directory.clone())
|
||||
.unwrap_or_else(|| sanitize_dir_name(&id, "skill"));
|
||||
let enabled_path = skills_dir.join(&name);
|
||||
let disabled_path = disabled_dir.join(&name);
|
||||
if enabled {
|
||||
if disabled_path.exists() && !enabled_path.exists() {
|
||||
fs::rename(&disabled_path, &enabled_path).or_else(|_| { copy_dir_recursive(&disabled_path, &enabled_path).map(|_| { let _ = fs::remove_dir_all(&disabled_path); }) })
|
||||
.map_err(|e| CodexxError::Config(format!("启用 Skill 失败: {e}")))?;
|
||||
}
|
||||
} else if enabled_path.exists() && !disabled_path.exists() {
|
||||
fs::rename(&enabled_path, &disabled_path).or_else(|_| { copy_dir_recursive(&enabled_path, &disabled_path).map(|_| { let _ = fs::remove_dir_all(&enabled_path); }) })
|
||||
.map_err(|e| CodexxError::Config(format!("禁用 Skill 失败: {e}")))?;
|
||||
}
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
fn install_skill_zip_inner(config_dir: Option<String>, file_name: String, bytes: Vec<u8>) -> Result<SkillsMcpActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
fs::create_dir_all(&skills_dir).map_err(|e| io_err(&skills_dir, e))?;
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| CodexxError::Config(format!("读取 ZIP 失败: {e}")))?;
|
||||
let tmp = app_home()?.join("tmp").join(format!("skill-zip-{}", Local::now().timestamp_millis()));
|
||||
fs::create_dir_all(&tmp).map_err(|e| io_err(&tmp, e))?;
|
||||
let mut total_size = 0u64;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| CodexxError::Config(format!("读取 ZIP 条目失败: {e}")))?;
|
||||
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else { continue; };
|
||||
total_size += file.size();
|
||||
if total_size > 80 * 1024 * 1024 { return Err(CodexxError::Config("ZIP 解压后超过 80MB".to_string())); }
|
||||
let out = tmp.join(path);
|
||||
if file.name().ends_with('/') {
|
||||
fs::create_dir_all(&out).map_err(|e| io_err(&out, e))?;
|
||||
} else {
|
||||
if let Some(parent) = out.parent() { fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?; }
|
||||
let mut outfile = fs::File::create(&out).map_err(|e| io_err(&out, e))?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| io_err(&out, e))?;
|
||||
}
|
||||
}
|
||||
let mut skill_dirs = Vec::new();
|
||||
fn find_skill_dirs(current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if current.join("SKILL.md").is_file() { out.push(current.to_path_buf()); return Ok(()); }
|
||||
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
|
||||
let entry = entry.map_err(|e| io_err(current, e))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() { find_skill_dirs(&path, out)?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
find_skill_dirs(&tmp, &mut skill_dirs)?;
|
||||
if skill_dirs.is_empty() { return Err(CodexxError::Config("ZIP 中没有找到 SKILL.md".to_string())); }
|
||||
let mut imported_skills = 0usize;
|
||||
for src in skill_dirs {
|
||||
let fallback = file_name.trim_end_matches(".zip");
|
||||
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
|
||||
let dst_name = sanitize_dir_name(dir_name, "skill");
|
||||
let dst = skills_dir.join(dst_name);
|
||||
if dst.exists() { fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?; }
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult { imported_skills, imported_mcp: 0, message: format!("已从 ZIP 安装 {imported_skills} 个 Skill"), state })
|
||||
}
|
||||
|
||||
fn check_skill_updates_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
|
||||
let mut next = build_skills_mcp_state_inner(config_dir)?;
|
||||
let conn = open_db()?;
|
||||
for skill in &mut next.skills {
|
||||
let old: Option<String> = conn.query_row("SELECT content_hash FROM managed_skills WHERE id = ?1", [&skill.id], |row| row.get(0)).ok();
|
||||
skill.update_status = match (&old, &skill.content_hash) {
|
||||
(Some(a), Some(b)) if a != b => "本地有变化".to_string(),
|
||||
(Some(_), Some(_)) => "已是最新".to_string(),
|
||||
_ => "已记录".to_string(),
|
||||
};
|
||||
save_managed_skill(skill)?;
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
fn sanitize_id(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut last_dash = false;
|
||||
@@ -2306,30 +2877,78 @@ fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> {
|
||||
.ok_or_else(|| CodexxError::Config(format!("{key} 不是 TOML table")))
|
||||
}
|
||||
|
||||
#[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))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取 Skills/MCP 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_existing_skills_mcp(config_dir: Option<String>) -> Result<SkillsMcpActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || import_existing_skills_mcp_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导入已有 Skills/MCP 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn toggle_codex_skill(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || toggle_codex_skill_inner(config_dir, id, enabled))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换 Skill 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn toggle_codex_mcp(config_dir: Option<String>, id: String, enabled: bool) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || toggle_codex_mcp_inner(config_dir, id, enabled))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换 MCP 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_skill_zip(config_dir: Option<String>, file_name: String, bytes: Vec<u8>) -> Result<SkillsMcpActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || install_skill_zip_inner(config_dir, file_name, bytes))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("ZIP 安装 Skill 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_skill_updates(config_dir: Option<String>) -> Result<SkillsMcpState> {
|
||||
tauri::async_runtime::spawn_blocking(move || check_skill_updates_inner(config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("检查 Skill 更新失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_startup_diagnostics(config_dir: Option<String>) -> Result<StartupDiagnostics> {
|
||||
startup_diagnostics_inner(config_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_session_sync_status(
|
||||
async fn get_session_sync_status(
|
||||
config_dir: Option<String>,
|
||||
target_provider: Option<String>,
|
||||
) -> Result<SessionSyncStatus> {
|
||||
session_sync_status_inner(config_dir, target_provider)
|
||||
tauri::async_runtime::spawn_blocking(move || session_sync_status_inner(config_dir, target_provider))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("读取会话状态失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sync_sessions_provider(
|
||||
async fn sync_sessions_provider(
|
||||
config_dir: Option<String>,
|
||||
target_provider: Option<String>,
|
||||
) -> Result<SessionSyncResult> {
|
||||
sync_sessions_provider_inner(config_dir, target_provider)
|
||||
tauri::async_runtime::spawn_blocking(move || sync_sessions_provider_inner(config_dir, target_provider))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("同步会话失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sync_selected_sessions_provider(input: SelectedSessionSyncInput) -> Result<SessionSyncResult> {
|
||||
sync_selected_sessions_provider_inner(input)
|
||||
async fn sync_selected_sessions_provider(input: SelectedSessionSyncInput) -> Result<SessionSyncResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || sync_selected_sessions_provider_inner(input))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("同步选中会话失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2365,8 +2984,10 @@ fn get_builtin_prompt_status() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn refresh_builtin_prompts() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
refresh_builtin_prompts_inner()
|
||||
async fn refresh_builtin_prompts() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
tauri::async_runtime::spawn_blocking(refresh_builtin_prompts_inner)
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("提示词后台更新失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2834,6 +3455,12 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_about_info,
|
||||
get_skills_mcp_state,
|
||||
import_existing_skills_mcp,
|
||||
toggle_codex_skill,
|
||||
toggle_codex_mcp,
|
||||
install_skill_zip,
|
||||
check_skill_updates,
|
||||
get_startup_diagnostics,
|
||||
get_session_sync_status,
|
||||
sync_sessions_provider,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+294
-10
@@ -34,7 +34,7 @@ import "./styles.css";
|
||||
type Lang = "zh" | "en";
|
||||
type ProviderMode = "list" | "form" | "official";
|
||||
type InstructionMode = "list" | "form";
|
||||
type Tab = "dashboard" | "provider" | "sessions" | "instruction" | "toml" | "settings" | "about";
|
||||
type Tab = "dashboard" | "provider" | "sessions" | "skillsMcp" | "instruction" | "toml" | "settings" | "about";
|
||||
|
||||
type InstructionTemplate = {
|
||||
id: string;
|
||||
@@ -183,6 +183,46 @@ type SessionSyncResult = {
|
||||
backupDir: string;
|
||||
};
|
||||
|
||||
type ManagedSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
path: string;
|
||||
contentHash?: string | null;
|
||||
updateStatus: string;
|
||||
};
|
||||
|
||||
type ManagedMcpServer = {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
summary: string;
|
||||
command?: string | null;
|
||||
url?: string | null;
|
||||
configJson: unknown;
|
||||
};
|
||||
|
||||
type SkillsMcpState = {
|
||||
codexDir: string;
|
||||
codexSkillsDir: string;
|
||||
disabledSkillsDir: string;
|
||||
skills: ManagedSkill[];
|
||||
mcpServers: ManagedMcpServer[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type SkillsMcpActionResult = {
|
||||
importedSkills: number;
|
||||
importedMcp: number;
|
||||
message: string;
|
||||
state: SkillsMcpState;
|
||||
};
|
||||
|
||||
type DiagnosticItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -257,6 +297,7 @@ const dict = {
|
||||
dashboard: "概览",
|
||||
provider: "供应商",
|
||||
sessions: "会话管理",
|
||||
skillsMcp: "技能和MCP",
|
||||
instruction: "指令提示词",
|
||||
toml: "TOML",
|
||||
settings: "设置",
|
||||
@@ -364,6 +405,7 @@ const dict = {
|
||||
dashboard: "Overview",
|
||||
provider: "Provider",
|
||||
sessions: "Sessions",
|
||||
skillsMcp: "Skills & MCP",
|
||||
instruction: "Prompt",
|
||||
toml: "TOML",
|
||||
settings: "Settings",
|
||||
@@ -763,8 +805,10 @@ function App() {
|
||||
const [releaseInfo, setReleaseInfo] = React.useState<ReleaseInfo>({ status: "idle" });
|
||||
const [updatePromptOpen, setUpdatePromptOpen] = React.useState(false);
|
||||
const [sessionStatus, setSessionStatus] = React.useState<SessionSyncStatus | null>(null);
|
||||
const [skillsMcpState, setSkillsMcpState] = React.useState<SkillsMcpState | null>(null);
|
||||
const [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
|
||||
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
|
||||
const [startupClosing, setStartupClosing] = React.useState(false);
|
||||
const [sessionQuery, setSessionQuery] = React.useState("");
|
||||
const [sessionGroupByCwd, setSessionGroupByCwd] = React.useState(true);
|
||||
const [selectedSessionIds, setSelectedSessionIds] = React.useState<string[]>([]);
|
||||
@@ -783,7 +827,9 @@ function App() {
|
||||
const [officialForm, setOfficialForm] = React.useState({ model: "gpt-5.5", authJson: "" });
|
||||
const autoUpdateCheckedRef = React.useRef(false);
|
||||
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const promptUpdateCheckedRef = React.useRef(false);
|
||||
const skillsMcpLoadedRef = React.useRef(false);
|
||||
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm, state), [providerForm, state]);
|
||||
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
|
||||
const currentInstructionId = instructionIdFromPath(state?.instructionFile);
|
||||
@@ -889,6 +935,9 @@ function App() {
|
||||
return (sessionStatus?.sessions || []).filter((item) => set.has(item.id) && item.needsSync).length;
|
||||
}, [selectedSessionIds, sessionStatus?.sessions]);
|
||||
|
||||
const enabledSkillCount = skillsMcpState?.skills.filter((item) => item.enabled).length ?? 0;
|
||||
const enabledMcpCount = skillsMcpState?.mcpServers.filter((item) => item.enabled).length ?? 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedSessionIds((ids) => ids.filter((id) => (sessionStatus?.sessions || []).some((item) => item.id === id)));
|
||||
}, [sessionStatus?.sessions]);
|
||||
@@ -1061,8 +1110,10 @@ function App() {
|
||||
};
|
||||
|
||||
const refreshBuiltinPrompts = async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
||||
setActionBusy("refreshPrompts");
|
||||
if (!quiet) setLoading(true);
|
||||
if (!quiet) {
|
||||
setActionBusy("refreshPrompts");
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const list = await invoke<BuiltinPromptStatus[]>("refresh_builtin_prompts");
|
||||
setBuiltinPromptStatus(list);
|
||||
@@ -1075,8 +1126,10 @@ function App() {
|
||||
} catch (e) {
|
||||
if (!quiet) setError(String(e));
|
||||
} finally {
|
||||
if (!quiet) setLoading(false);
|
||||
setActionBusy("");
|
||||
if (!quiet) {
|
||||
setLoading(false);
|
||||
setActionBusy("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1246,10 +1299,112 @@ function App() {
|
||||
React.useEffect(() => {
|
||||
if (tab !== "instruction" || promptUpdateCheckedRef.current) return;
|
||||
promptUpdateCheckedRef.current = true;
|
||||
void refreshBuiltinPrompts({ quiet: true });
|
||||
const timer = window.setTimeout(() => {
|
||||
void refreshBuiltinPrompts({ quiet: true });
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
|
||||
const loadSkillsMcp = React.useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
||||
if (!quiet) {
|
||||
setActionBusy("loadSkillsMcp");
|
||||
setError("");
|
||||
}
|
||||
try {
|
||||
const result = await invoke<SkillsMcpState>("get_skills_mcp_state", { configDir: configDir || null });
|
||||
setSkillsMcpState(result);
|
||||
} catch (e) {
|
||||
if (!quiet) setError(String(e));
|
||||
} finally {
|
||||
if (!quiet) setActionBusy("");
|
||||
}
|
||||
}, [configDir]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab !== "skillsMcp" || skillsMcpLoadedRef.current) return;
|
||||
skillsMcpLoadedRef.current = true;
|
||||
void loadSkillsMcp();
|
||||
}, [tab, loadSkillsMcp]);
|
||||
|
||||
const importExistingSkillsMcp = async () => {
|
||||
setActionBusy("importExistingSkillsMcp");
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkillsMcpActionResult>("import_existing_skills_mcp", { configDir: configDir || null });
|
||||
setSkillsMcpState(result.state);
|
||||
setToast(result.message);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const checkSkillUpdatesAction = async () => {
|
||||
setActionBusy("checkSkillUpdates");
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkillsMcpState>("check_skill_updates", { configDir: configDir || null });
|
||||
setSkillsMcpState(result);
|
||||
setToast(lang === "zh" ? "Skills 更新状态已刷新" : "Skill update status refreshed");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSkillEnabled = async (id: string, enabled: boolean) => {
|
||||
setActionBusy(`skill:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkillsMcpState>("toggle_codex_skill", { configDir: configDir || null, id, enabled });
|
||||
setSkillsMcpState(result);
|
||||
setToast(enabled ? (lang === "zh" ? "Skill 已启用" : "Skill enabled") : (lang === "zh" ? "Skill 已禁用" : "Skill disabled"));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMcpEnabled = async (id: string, enabled: boolean) => {
|
||||
setActionBusy(`mcp:${id}`);
|
||||
setError("");
|
||||
try {
|
||||
const result = await invoke<SkillsMcpState>("toggle_codex_mcp", { configDir: configDir || null, id, enabled });
|
||||
setSkillsMcpState(result);
|
||||
setToast(enabled ? (lang === "zh" ? "MCP 已启用" : "MCP enabled") : (lang === "zh" ? "MCP 已禁用" : "MCP disabled"));
|
||||
void refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const installSkillZipFile = async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
setError(lang === "zh" ? "请选择 .zip 技能包" : "Please choose a .zip skill package");
|
||||
return;
|
||||
}
|
||||
setActionBusy("installSkillZip");
|
||||
setError("");
|
||||
try {
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
const result = await invoke<SkillsMcpActionResult>("install_skill_zip", { configDir: configDir || null, fileName: file.name, bytes });
|
||||
setSkillsMcpState(result.state);
|
||||
setToast(result.message);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
if (skillZipImportRef.current) skillZipImportRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const loadCcSwitchOfficialAuth = async (showToast = true) => {
|
||||
const candidate = await invoke<OfficialAuthCandidate | null>("read_ccswitch_official_auth", { dbPath: null });
|
||||
if (candidate) {
|
||||
@@ -1397,13 +1552,18 @@ function App() {
|
||||
|
||||
const closeStartupWizard = () => {
|
||||
localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
|
||||
setStartupWizardOpen(false);
|
||||
setStartupClosing(true);
|
||||
window.setTimeout(() => {
|
||||
setStartupWizardOpen(false);
|
||||
setStartupClosing(false);
|
||||
}, 260);
|
||||
};
|
||||
|
||||
const navItems: Array<[Tab, string, React.ReactNode]> = [
|
||||
["dashboard", t.nav.dashboard, <Layers3 size={18} />],
|
||||
["provider", t.nav.provider, <Zap size={18} />],
|
||||
["sessions", t.nav.sessions, <History size={18} />],
|
||||
["skillsMcp", t.nav.skillsMcp, <Layers3 size={18} />],
|
||||
["instruction", t.nav.instruction, <Sparkles size={18} />],
|
||||
["toml", t.nav.toml, <FileCode2 size={18} />],
|
||||
["settings", t.nav.settings, <Settings size={18} />],
|
||||
@@ -1495,7 +1655,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{startupWizardOpen && startupDiagnostics && (
|
||||
<div className="startup-mask">
|
||||
<div className={cx("startup-mask", startupClosing && "closing")}>
|
||||
<div className="startup-card glass">
|
||||
<div className="startup-head">
|
||||
<div>
|
||||
@@ -1543,9 +1703,13 @@ function App() {
|
||||
)}
|
||||
|
||||
{!state ? (
|
||||
<div className="panel glass center-panel">
|
||||
<Loader2 className="spin" />
|
||||
<div className="panel glass center-panel boot-panel">
|
||||
<div className="boot-logo-wrap">
|
||||
<div className="boot-logo">Codex-X</div>
|
||||
<div className="boot-orbit" />
|
||||
</div>
|
||||
<p>{t.loadingConfig}</p>
|
||||
<div className="boot-progress"><span /></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1910,6 +2074,126 @@ function App() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "skillsMcp" && (
|
||||
<section className="panel glass skills-mcp-panel">
|
||||
<div className="panel-head provider-title-row">
|
||||
<div>
|
||||
<p className="eyebrow">Skills / MCP</p>
|
||||
<h3>{lang === "zh" ? "技能和 MCP" : "Skills & MCP"}</h3>
|
||||
<p className="muted-desc">{lang === "zh" ? "管理 Codex 当前可用的 Skills 与 MCP:导入已有、从 ZIP 安装、启用或禁用。" : "Manage Codex Skills and MCP servers: import existing items, install ZIP packages, enable or disable."}</p>
|
||||
</div>
|
||||
<div className="provider-title-actions">
|
||||
<input
|
||||
ref={skillZipImportRef}
|
||||
className="hidden-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => void installSkillZipFile(e.target.files?.[0])}
|
||||
/>
|
||||
<button className="ghost-btn add-provider-btn lively-btn" onClick={() => void loadSkillsMcp()} disabled={actionBusy === "loadSkillsMcp"}>
|
||||
{actionBusy === "loadSkillsMcp" ? <Loader2 size={18} className="spin" /> : <RefreshCw size={18} />} {lang === "zh" ? "刷新" : "Refresh"}
|
||||
</button>
|
||||
<button className="secondary-btn add-provider-btn lively-btn" onClick={importExistingSkillsMcp} disabled={Boolean(actionBusy)}>
|
||||
{actionBusy === "importExistingSkillsMcp" ? <Loader2 size={18} className="spin" /> : <Download size={18} />} {lang === "zh" ? "导入已有" : "Import existing"}
|
||||
</button>
|
||||
<button className="secondary-btn add-provider-btn lively-btn" onClick={() => skillZipImportRef.current?.click()} disabled={Boolean(actionBusy)}>
|
||||
{actionBusy === "installSkillZip" ? <Loader2 size={18} className="spin" /> : <Upload size={18} />} {lang === "zh" ? "从 ZIP 安装" : "Install ZIP"}
|
||||
</button>
|
||||
<button className="primary-btn add-provider-btn lively-btn" onClick={checkSkillUpdatesAction} disabled={Boolean(actionBusy)}>
|
||||
{actionBusy === "checkSkillUpdates" ? <Loader2 size={18} className="spin" /> : <Sparkles size={18} />} {lang === "zh" ? "检查更新" : "Check updates"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!skillsMcpState ? (
|
||||
<div className="session-empty skills-loading">
|
||||
<Loader2 className="spin" size={22} />
|
||||
<span>{lang === "zh" ? "正在读取本地 Skills / MCP..." : "Loading local Skills / MCP..."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="skills-mcp-summary">
|
||||
<StatCard icon={<Sparkles size={20} />} label={lang === "zh" ? "已启用 Skills" : "Enabled Skills"} value={`${enabledSkillCount}/${skillsMcpState.skills.length}`} ok={enabledSkillCount > 0} />
|
||||
<StatCard icon={<TerminalSquare size={20} />} label={lang === "zh" ? "已启用 MCP" : "Enabled MCP"} value={`${enabledMcpCount}/${skillsMcpState.mcpServers.length}`} ok={enabledMcpCount > 0} />
|
||||
<div className="skills-path-card">
|
||||
<span>~/.codex/skills</span>
|
||||
<code>{skillsMcpState.codexSkillsDir}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skills-mcp-columns">
|
||||
<section className="skills-mcp-card">
|
||||
<div className="skills-mcp-card-head">
|
||||
<div>
|
||||
<strong>Skills</strong>
|
||||
<p>{lang === "zh" ? "启用后会放入 Codex skills 目录;禁用会移动到 Codex-X 的禁用目录。" : "Enabled skills live in the Codex skills directory; disabled skills are moved into Codex-X storage."}</p>
|
||||
</div>
|
||||
<span className="mini-tag ok">{skillsMcpState.skills.length}</span>
|
||||
</div>
|
||||
<div className="skills-mcp-list">
|
||||
{skillsMcpState.skills.length === 0 ? (
|
||||
<div className="session-empty compact"><Sparkles size={18} /><span>{lang === "zh" ? "还没有发现 Skills,点击导入已有或从 ZIP 安装。" : "No Skills found. Import existing or install a ZIP."}</span></div>
|
||||
) : skillsMcpState.skills.map((skill) => (
|
||||
<article className={cx("skill-mcp-row", skill.enabled && "selected")} key={skill.id}>
|
||||
<div className="instruction-icon custom"><Sparkles size={20} /></div>
|
||||
<div className="skill-mcp-main">
|
||||
<strong>{skill.name}</strong>
|
||||
<p>{skill.description || (lang === "zh" ? "无描述" : "No description")}</p>
|
||||
<code title={skill.path}>{skill.directory}</code>
|
||||
</div>
|
||||
<div className="skill-mcp-meta">
|
||||
<span className={cx("mini-tag", skill.enabled ? "ok" : undefined)}>{skill.enabled ? (lang === "zh" ? "已启用" : "Enabled") : (lang === "zh" ? "已禁用" : "Disabled")}</span>
|
||||
<small>{skill.updateStatus}</small>
|
||||
</div>
|
||||
<button className={cx(skill.enabled ? "ghost-btn" : "secondary-btn", "small", "lively-btn")} onClick={() => void toggleSkillEnabled(skill.id, !skill.enabled)} disabled={actionBusy === `skill:${skill.id}`}>
|
||||
{actionBusy === `skill:${skill.id}` ? <Loader2 size={14} className="spin" /> : null}{skill.enabled ? (lang === "zh" ? "禁用" : "Disable") : (lang === "zh" ? "启用" : "Enable")}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="skills-mcp-card">
|
||||
<div className="skills-mcp-card-head">
|
||||
<div>
|
||||
<strong>MCP</strong>
|
||||
<p>{lang === "zh" ? "启用会写入 ~/.codex/config.toml 的 [mcp_servers];禁用会移除但保留在 Codex-X。" : "Enabling writes to ~/.codex/config.toml [mcp_servers]; disabling removes it but keeps a Codex-X copy."}</p>
|
||||
</div>
|
||||
<span className="mini-tag ok">{skillsMcpState.mcpServers.length}</span>
|
||||
</div>
|
||||
<div className="skills-mcp-list">
|
||||
{skillsMcpState.mcpServers.length === 0 ? (
|
||||
<div className="session-empty compact"><TerminalSquare size={18} /><span>{lang === "zh" ? "还没有发现 MCP。当前会从 config.toml 导入已有 MCP。" : "No MCP server found. Existing config.toml MCP entries can be imported."}</span></div>
|
||||
) : skillsMcpState.mcpServers.map((server) => (
|
||||
<article className={cx("skill-mcp-row", server.enabled && "selected")} key={server.id}>
|
||||
<div className="instruction-icon"><TerminalSquare size={20} /></div>
|
||||
<div className="skill-mcp-main">
|
||||
<strong>{server.name || server.id}</strong>
|
||||
<p title={server.summary}>{server.summary || server.transport}</p>
|
||||
<code>{server.transport} · {server.source}</code>
|
||||
</div>
|
||||
<div className="skill-mcp-meta">
|
||||
<span className={cx("mini-tag", server.enabled ? "ok" : undefined)}>{server.enabled ? (lang === "zh" ? "已启用" : "Enabled") : (lang === "zh" ? "已禁用" : "Disabled")}</span>
|
||||
</div>
|
||||
<button className={cx(server.enabled ? "ghost-btn" : "secondary-btn", "small", "lively-btn")} onClick={() => void toggleMcpEnabled(server.id, !server.enabled)} disabled={actionBusy === `mcp:${server.id}`}>
|
||||
{actionBusy === `mcp:${server.id}` ? <Loader2 size={14} className="spin" /> : null}{server.enabled ? (lang === "zh" ? "关闭" : "Disable") : (lang === "zh" ? "开启" : "Enable")}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{skillsMcpState.warnings.length > 0 && (
|
||||
<div className="session-warning-list">
|
||||
{skillsMcpState.warnings.map((item, index) => <p key={index}><AlertCircle size={15} /> {item}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "instruction" && (
|
||||
<section className="panel glass instruction-panel simple-instruction-panel">
|
||||
{instructionMode === "list" ? (
|
||||
|
||||
@@ -2002,3 +2002,229 @@ html, body, #root {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Smoother boot / first-run transitions */
|
||||
.boot-panel {
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
animation: boot-panel-in .42s cubic-bezier(.2,.8,.2,1) both;
|
||||
}
|
||||
.boot-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(45, 170, 255, .24), transparent 62%);
|
||||
filter: blur(6px);
|
||||
animation: boot-glow-drift 4.8s ease-in-out infinite;
|
||||
}
|
||||
.boot-logo-wrap {
|
||||
position: relative;
|
||||
width: 174px;
|
||||
height: 174px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.boot-logo {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 126px;
|
||||
height: 126px;
|
||||
border-radius: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #f7fbff;
|
||||
font-weight: 900;
|
||||
letter-spacing: -.06em;
|
||||
font-size: 24px;
|
||||
background: linear-gradient(145deg, rgba(33, 96, 255, .92), rgba(18, 218, 255, .74));
|
||||
border: 1px solid rgba(255,255,255,.28);
|
||||
box-shadow: 0 26px 70px rgba(20, 130, 255, .26), inset 0 1px 0 rgba(255,255,255,.38);
|
||||
animation: boot-logo-float 2.8s ease-in-out infinite;
|
||||
}
|
||||
.boot-orbit {
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(113, 205, 255, .22);
|
||||
border-top-color: rgba(89, 210, 255, .9);
|
||||
border-right-color: rgba(108, 134, 255, .55);
|
||||
animation: boot-orbit-spin 1.35s linear infinite;
|
||||
}
|
||||
.boot-progress {
|
||||
width: min(320px, 68%);
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,.08);
|
||||
border: 1px solid rgba(255,255,255,.09);
|
||||
}
|
||||
.boot-progress span {
|
||||
display: block;
|
||||
width: 44%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, #43dfff, #6c7dff, transparent);
|
||||
animation: boot-progress-run 1.25s ease-in-out infinite;
|
||||
}
|
||||
.startup-mask {
|
||||
animation: startup-mask-in .24s ease-out both;
|
||||
}
|
||||
.startup-mask.closing {
|
||||
animation: startup-mask-out .26s ease-in both;
|
||||
}
|
||||
.startup-card {
|
||||
animation: startup-card-in .46s cubic-bezier(.16,1,.3,1) both;
|
||||
}
|
||||
.startup-mask.closing .startup-card {
|
||||
animation: startup-card-out .24s ease-in both;
|
||||
}
|
||||
.startup-check-item {
|
||||
animation: startup-item-in .42s cubic-bezier(.2,.8,.2,1) both;
|
||||
}
|
||||
.startup-check-item:nth-child(2) { animation-delay: .04s; }
|
||||
.startup-check-item:nth-child(3) { animation-delay: .08s; }
|
||||
.startup-check-item:nth-child(4) { animation-delay: .12s; }
|
||||
@keyframes boot-panel-in {
|
||||
from { opacity: 0; transform: translateY(10px) scale(.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes boot-logo-float {
|
||||
0%, 100% { transform: translateY(0); filter: brightness(1); }
|
||||
50% { transform: translateY(-7px); filter: brightness(1.08); }
|
||||
}
|
||||
@keyframes boot-orbit-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes boot-progress-run {
|
||||
0% { transform: translateX(-110%); }
|
||||
100% { transform: translateX(250%); }
|
||||
}
|
||||
@keyframes boot-glow-drift {
|
||||
0%, 100% { transform: translate(-120px, -80px) scale(.92); opacity: .75; }
|
||||
50% { transform: translate(120px, 60px) scale(1.12); opacity: 1; }
|
||||
}
|
||||
@keyframes startup-mask-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes startup-mask-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes startup-card-in {
|
||||
from { opacity: 0; transform: translateY(18px) scale(.975); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes startup-card-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(10px) scale(.985); }
|
||||
}
|
||||
@keyframes startup-item-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.boot-panel,
|
||||
.boot-panel::before,
|
||||
.boot-logo,
|
||||
.boot-orbit,
|
||||
.boot-progress span,
|
||||
.startup-mask,
|
||||
.startup-card,
|
||||
.startup-check-item {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Page switch transition */
|
||||
.content > .panel,
|
||||
.content > .grid,
|
||||
.content > .about-page,
|
||||
.content > .settings-panel,
|
||||
.content > .update-strip {
|
||||
animation: page-soft-in .24s cubic-bezier(.2,.8,.2,1) both;
|
||||
}
|
||||
@keyframes page-soft-in {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.992); filter: blur(2px); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
}
|
||||
|
||||
/* Skills & MCP */
|
||||
.skills-mcp-panel { display: grid; gap: 16px; }
|
||||
.skills-mcp-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 240px) minmax(180px, 240px) 1fr;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.skills-path-card {
|
||||
min-height: 110px;
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.035));
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.skills-path-card span { color: #91a0bb; font-size: 13px; font-weight: 800; }
|
||||
.skills-path-card code { color: #d9e6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.skills-mcp-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.skills-mcp-card {
|
||||
border-radius: 26px;
|
||||
padding: 18px;
|
||||
background: rgba(4, 12, 27, .34);
|
||||
border: 1px solid rgba(255,255,255,.09);
|
||||
min-width: 0;
|
||||
}
|
||||
.skills-mcp-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.skills-mcp-card-head strong { font-size: 18px; color: #f5f9ff; }
|
||||
.skills-mcp-card-head p { margin: 5px 0 0; color: #8fa0bc; line-height: 1.55; font-size: 13px; }
|
||||
.skills-mcp-list { display: grid; gap: 12px; max-height: calc(100vh - 365px); overflow: auto; padding-right: 4px; }
|
||||
.skill-mcp-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 92px;
|
||||
padding: 14px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.065), rgba(255,255,255,.03));
|
||||
border: 1px solid rgba(255,255,255,.09);
|
||||
transition: transform .18s ease, border-color .18s ease, background .18s ease;
|
||||
}
|
||||
.skill-mcp-row:hover { transform: translateY(-1px); border-color: rgba(87, 173, 255, .35); }
|
||||
.skill-mcp-row.selected { border-color: rgba(35, 207, 255, .62); background: linear-gradient(180deg, rgba(36,135,255,.16), rgba(17,75,112,.12)); }
|
||||
.skill-mcp-main { min-width: 0; }
|
||||
.skill-mcp-main strong { display: block; color: #f5f9ff; margin-bottom: 4px; }
|
||||
.skill-mcp-main p { margin: 0 0 6px; color: #9caac4; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.skill-mcp-main code { display: block; color: #bcd0ef; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.skill-mcp-meta { display: grid; justify-items: end; gap: 6px; color: #8fa0bc; font-size: 12px; }
|
||||
.session-empty.compact { min-height: 96px; }
|
||||
.skills-loading { min-height: 280px; }
|
||||
@media (max-width: 1280px) {
|
||||
.skills-mcp-summary { grid-template-columns: 1fr; }
|
||||
.skills-mcp-columns { grid-template-columns: 1fr; }
|
||||
.skills-mcp-list { max-height: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.content > .panel,
|
||||
.content > .grid,
|
||||
.content > .about-page,
|
||||
.content > .settings-panel,
|
||||
.content > .update-strip { animation: none !important; }
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Reference in New Issue
Block a user