mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
304dcddb1f |
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [v0.2.31] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
- 调整 macOS Toast 展示位置:Toast 现在直接挂在窗口根层,不再被内容区域下移影响,提示位置更靠上、更自然。
|
||||
- 优化 Skills ZIP 安装体验:从 ZIP 安装 Skill 时会读取 `SKILL.md` 里的真实 `name` / `description`,不再显示 `skill-zip-时间戳` 这类临时目录名。
|
||||
- Skills 页面刷新时会自动识别旧版 `skill-zip-*` 目录,并尝试重命名为 Skill 自身名称。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复启用第三方供应商后,关闭并重新打开 Codex-X 不再显示“当前启用”的问题。
|
||||
- 修复 Windows / Linux 上供应商编辑页无法正常下拉滚动的问题,编辑 `config.toml` 时不再需要用 PageDown 绕过。
|
||||
- 修复 Toast 受内容容器 `contain` 影响导致 macOS 位置调整不生效的问题。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 Skill 元数据读取与旧 ZIP Skill 目录自动修正的单元测试。
|
||||
|
||||
## [v0.2.30] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.2.30",
|
||||
"version": "0.2.31",
|
||||
"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.30"
|
||||
version = "0.2.31"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.2.30"
|
||||
version = "0.2.31"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1154,31 +1154,100 @@ fn compute_dir_hash(dir: &Path) -> Result<String> {
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn clean_skill_metadata_value(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches('`')
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
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 mut frontmatter_name: Option<String> = None;
|
||||
let mut frontmatter_desc: Option<String> = None;
|
||||
let mut heading_title: Option<String> = None;
|
||||
let mut body_desc: Option<String> = None;
|
||||
let mut in_frontmatter = false;
|
||||
let mut frontmatter_seen = false;
|
||||
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if index == 0 && trimmed == "---" {
|
||||
in_frontmatter = true;
|
||||
frontmatter_seen = true;
|
||||
continue;
|
||||
}
|
||||
if in_frontmatter {
|
||||
if trimmed == "---" {
|
||||
in_frontmatter = false;
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = trimmed.split_once(':') {
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let value = clean_skill_metadata_value(value);
|
||||
if key == "name" && frontmatter_name.is_none() && !value.is_empty() {
|
||||
frontmatter_name = Some(value);
|
||||
} else if key == "description" && frontmatter_desc.is_none() && !value.is_empty() {
|
||||
frontmatter_desc = Some(value);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if title.is_none() && trimmed.starts_with('#') {
|
||||
title = Some(trimmed.trim_start_matches('#').trim().to_string());
|
||||
if heading_title.is_none() && trimmed.starts_with('#') {
|
||||
heading_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());
|
||||
if body_desc.is_none()
|
||||
&& !trimmed.starts_with('#')
|
||||
&& !trimmed.starts_with("---")
|
||||
&& !frontmatter_seen
|
||||
{
|
||||
body_desc = Some(clean_skill_metadata_value(trimmed));
|
||||
break;
|
||||
}
|
||||
}
|
||||
(
|
||||
title
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| fallback.to_string()),
|
||||
desc.filter(|s| !s.is_empty()),
|
||||
)
|
||||
|
||||
let title = frontmatter_name
|
||||
.or(heading_title)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| fallback.to_string());
|
||||
let desc = frontmatter_desc.or(body_desc).filter(|s| !s.is_empty());
|
||||
(title, desc)
|
||||
}
|
||||
|
||||
fn normalize_legacy_zip_skill_dirs(base: &Path) -> 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();
|
||||
if !directory.starts_with("skill-zip-") {
|
||||
continue;
|
||||
}
|
||||
let (name, _) = read_skill_metadata(&path, &directory);
|
||||
let dst_name = sanitize_dir_name(&name, "skill");
|
||||
if dst_name == directory || dst_name.starts_with("skill-zip-") {
|
||||
continue;
|
||||
}
|
||||
let dst = base.join(dst_name);
|
||||
if dst.exists() {
|
||||
continue;
|
||||
}
|
||||
fs::rename(&path, &dst).map_err(|e| io_err(&dst, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_managed_skill(skill: &ManagedSkill) -> Result<()> {
|
||||
@@ -1457,6 +1526,12 @@ fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result<SkillsMcpS
|
||||
let mut warnings = Vec::new();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
if let Err(e) = normalize_legacy_zip_skill_dirs(&skills_dir) {
|
||||
warnings.push(format!("修正 ZIP Skill 目录名失败: {e}"));
|
||||
}
|
||||
if let Err(e) = normalize_legacy_zip_skill_dirs(&disabled_dir) {
|
||||
warnings.push(format!("修正已禁用 ZIP Skill 目录名失败: {e}"));
|
||||
}
|
||||
if let Err(e) = scan_skill_dir(&skills_dir, true, "Codex", &mut skills, &mut seen) {
|
||||
warnings.push(e.to_string());
|
||||
}
|
||||
@@ -1888,7 +1963,8 @@ fn install_skill_zip_inner(
|
||||
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 (skill_name, _) = read_skill_metadata(&src, dir_name);
|
||||
let dst_name = sanitize_dir_name(&skill_name, "skill");
|
||||
let dst = skills_dir.join(dst_name);
|
||||
if dst.exists() {
|
||||
fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?;
|
||||
@@ -4763,6 +4839,55 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_metadata_reads_frontmatter_name_before_directory() {
|
||||
let dir = temp_codex_dir("skill-frontmatter").join("skill-zip-123");
|
||||
fs::create_dir_all(&dir).expect("create skill dir");
|
||||
write_text(
|
||||
&dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: ctf-sandbox-runner
|
||||
description: Resume authorized CTF sandbox projects.
|
||||
---
|
||||
|
||||
# CTF Sandbox Runner
|
||||
"#,
|
||||
)
|
||||
.expect("write skill");
|
||||
|
||||
let (name, desc) = read_skill_metadata(&dir, "skill-zip-123");
|
||||
assert_eq!(name, "ctf-sandbox-runner");
|
||||
assert_eq!(
|
||||
desc.as_deref(),
|
||||
Some("Resume authorized CTF sandbox projects.")
|
||||
);
|
||||
|
||||
let root = dir.parent().unwrap().to_path_buf();
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_legacy_zip_skill_dir_renames_to_metadata_name() {
|
||||
let root = temp_codex_dir("skill-normalize");
|
||||
let dir = root.join("skill-zip-1783334291187");
|
||||
fs::create_dir_all(&dir).expect("create legacy skill dir");
|
||||
write_text(
|
||||
&dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: mission-keeper
|
||||
description: Keep long investigations aligned.
|
||||
---
|
||||
"#,
|
||||
)
|
||||
.expect("write skill");
|
||||
|
||||
normalize_legacy_zip_skill_dirs(&root).expect("normalize");
|
||||
assert!(!dir.exists());
|
||||
assert!(root.join("mission-keeper").join("SKILL.md").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_provider_writes_ccswitch_custom_provider_and_api_key_auth_mode() {
|
||||
let codex_dir = temp_codex_dir("switch-provider");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.2.30",
|
||||
"version": "0.2.31",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+42
-26
@@ -976,7 +976,20 @@ function App() {
|
||||
|
||||
const currentProvider = state?.providers.find((p) => p.isCurrent);
|
||||
const liveProviderId = (state?.modelProvider || "openai").trim();
|
||||
const effectiveActiveProviderId = liveProviderId === "custom" ? activeProviderId : liveProviderId;
|
||||
const liveCustomProvider = React.useMemo(() => (state?.providers || []).find((item) => item.id === "custom"), [state?.providers]);
|
||||
const inferredActiveProviderId = React.useMemo(() => {
|
||||
if (liveProviderId !== "custom" || activeProviderId) return activeProviderId;
|
||||
const liveBaseUrl = (liveCustomProvider?.baseUrl || "").replace(/\/+$/, "");
|
||||
const liveWireApi = liveCustomProvider?.wireApi || "responses";
|
||||
const liveModel = state?.model || "";
|
||||
const matched = savedProviders.find((item) =>
|
||||
item.baseUrl.replace(/\/+$/, "") === liveBaseUrl &&
|
||||
(item.model || "") === liveModel &&
|
||||
(item.wireApi || "responses") === liveWireApi,
|
||||
);
|
||||
return matched?.id || "";
|
||||
}, [activeProviderId, liveCustomProvider?.baseUrl, liveCustomProvider?.wireApi, liveProviderId, savedProviders, state?.model]);
|
||||
const effectiveActiveProviderId = liveProviderId === "custom" ? inferredActiveProviderId : liveProviderId;
|
||||
const currentInstructionPath = (state?.instructionFile || "").replace(/\\/g, "/");
|
||||
const currentInstructionFilename = currentInstructionPath.split("/").pop() || "";
|
||||
const detectedRows = React.useMemo(() => {
|
||||
@@ -1125,6 +1138,7 @@ function App() {
|
||||
}, [autoSessionSync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!state) return;
|
||||
if (liveProviderId !== "custom") {
|
||||
if (activeProviderId) {
|
||||
localStorage.removeItem(ACTIVE_PROVIDER_KEY);
|
||||
@@ -1132,11 +1146,12 @@ function App() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!savedProviders.length) return;
|
||||
if (activeProviderId && !savedProviders.some((item) => item.id === activeProviderId)) {
|
||||
localStorage.removeItem(ACTIVE_PROVIDER_KEY);
|
||||
setActiveProviderId("");
|
||||
}
|
||||
}, [activeProviderId, liveProviderId, savedProviders]);
|
||||
}, [activeProviderId, liveProviderId, savedProviders, state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoSessionSync || autoSessionSyncRanRef.current || !state?.codexDir) return;
|
||||
@@ -1808,11 +1823,36 @@ function App() {
|
||||
["about", t.nav.about, <Info size={18} />],
|
||||
];
|
||||
|
||||
const toastLayer = toast ? (() => {
|
||||
const [title, ...rest] = toast.split("\n");
|
||||
const message = rest.join("\n").trim();
|
||||
return (
|
||||
<div className="toast ok" onAnimationEnd={() => setToast("")}>
|
||||
<div className="toast-icon"><CheckCircle2 size={16} /></div>
|
||||
<div className="toast-copy">
|
||||
<strong>{title}</strong>
|
||||
{message && <span>{message}</span>}
|
||||
</div>
|
||||
<button className="toast-close" onClick={() => setToast("")}>×</button>
|
||||
</div>
|
||||
);
|
||||
})() : error ? (
|
||||
<div className="toast error">
|
||||
<div className="toast-icon"><AlertCircle size={16} /></div>
|
||||
<div className="toast-copy">
|
||||
<strong>{lang === "zh" ? "操作失败" : "Action failed"}</strong>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<button className="toast-close" onClick={() => setError("")}>×</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className={cx("app-shell", isMacRuntime && "mac-shell")}>
|
||||
{isMacRuntime && <div className="window-drag-strip" data-tauri-drag-region />}
|
||||
<div className="orb orb-a" />
|
||||
<div className="orb orb-b" />
|
||||
{toastLayer}
|
||||
|
||||
<aside className="sidebar glass">
|
||||
<div className="brand">
|
||||
@@ -1854,30 +1894,6 @@ function App() {
|
||||
</header>
|
||||
)}
|
||||
|
||||
{toast && (() => {
|
||||
const [title, ...rest] = toast.split("\n");
|
||||
const message = rest.join("\n").trim();
|
||||
return (
|
||||
<div className="toast ok" onAnimationEnd={() => setToast("")}>
|
||||
<div className="toast-icon"><CheckCircle2 size={16} /></div>
|
||||
<div className="toast-copy">
|
||||
<strong>{title}</strong>
|
||||
{message && <span>{message}</span>}
|
||||
</div>
|
||||
<button className="toast-close" onClick={() => setToast("")}>×</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{error && (
|
||||
<div className="toast error">
|
||||
<div className="toast-icon"><AlertCircle size={16} /></div>
|
||||
<div className="toast-copy">
|
||||
<strong>{lang === "zh" ? "操作失败" : "Action failed"}</strong>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<button className="toast-close" onClick={() => setError("")}>×</button>
|
||||
</div>
|
||||
)}
|
||||
{updatePromptOpen && releaseInfo.hasUpdate && (
|
||||
<div className="update-mask" onClick={() => setUpdatePromptOpen(false)}>
|
||||
<div className="update-dialog glass" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -198,7 +198,7 @@ input, select, textarea {
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 24px;
|
||||
top: 14px;
|
||||
z-index: 50;
|
||||
transform: translateX(-50%);
|
||||
min-width: 360px;
|
||||
@@ -260,7 +260,7 @@ input, select, textarea {
|
||||
padding: 0;
|
||||
}
|
||||
.toast-close:hover { color: #fff; }
|
||||
.app-shell.mac-shell .toast { top: calc(18px + var(--mac-titlebar-safe)); }
|
||||
.app-shell.mac-shell .toast { top: 68px; }
|
||||
@keyframes toast {
|
||||
0% { opacity: 0; transform: translate(-50%, -10px) scale(.98); }
|
||||
12%, 84% { opacity: 1; transform: translate(-50%, 0) scale(1); }
|
||||
@@ -1362,6 +1362,15 @@ html, body, #root {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Provider edit page can be taller than one viewport; let the outer content pane scroll.
|
||||
Keep only the provider list page using the fixed internal list scroller. */
|
||||
.panel.glass.provider-panel.provider-edit-panel {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
min-height: calc(100vh - var(--shell-vertical-pad));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Simpler instruction prompt page: aligned status + enable/disable controls. */
|
||||
.simple-instruction-panel .instruction-row {
|
||||
grid-template-columns: minmax(320px, 1fr) 86px 280px;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.2.30",
|
||||
"version": "0.2.31",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Reference in New Issue
Block a user