mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ba23e5e77 | ||
|
|
9cbd7bf351 |
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [v0.2.20] - 2026-07-06
|
||||
|
||||
- 继续优化指令提示词页性能:提示词列表/状态读取、保存、导入、启用、禁用等后端操作改为后台 worker,避免切页或启用模板时阻塞 UI。
|
||||
- 修复外部自定义提示词重复保存问题:切换到内置模板前会按文件名与内容双重去重,并自动清理历史 `external-*` 重复项。
|
||||
- 优化启动加载体验:启动页增加最短展示、退出淡出与动态过渡,避免从欢迎页突然跳到主页。
|
||||
|
||||
## [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.20",
|
||||
"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.20"
|
||||
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.20"
|
||||
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"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.20",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+310
-11
@@ -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[]>([]);
|
||||
@@ -772,6 +816,8 @@ function App() {
|
||||
const [backups, setBackups] = React.useState<BackupEntry[]>([]);
|
||||
const [configDir, setConfigDir] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [bootVisible, setBootVisible] = React.useState(true);
|
||||
const [bootLeaving, setBootLeaving] = React.useState(false);
|
||||
const [toast, setToast] = React.useState<string>("");
|
||||
const [error, setError] = React.useState<string>("");
|
||||
const [providerForm, setProviderForm] = React.useState<SavedProvider>(defaultProviderForm);
|
||||
@@ -783,7 +829,10 @@ 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 bootStartedAtRef = React.useRef(Date.now());
|
||||
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm, state), [providerForm, state]);
|
||||
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
|
||||
const currentInstructionId = instructionIdFromPath(state?.instructionFile);
|
||||
@@ -806,6 +855,18 @@ function App() {
|
||||
}
|
||||
}, [providerMode, providerTomlDirty, providerTomlPreview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!state || !bootVisible || bootLeaving) return undefined;
|
||||
const elapsed = Date.now() - bootStartedAtRef.current;
|
||||
const minBootMs = 920;
|
||||
const leaveDelay = Math.max(120, minBootMs - elapsed);
|
||||
const leaveTimer = window.setTimeout(() => {
|
||||
setBootLeaving(true);
|
||||
window.setTimeout(() => setBootVisible(false), 300);
|
||||
}, leaveDelay);
|
||||
return () => window.clearTimeout(leaveTimer);
|
||||
}, [bootLeaving, bootVisible, state]);
|
||||
|
||||
|
||||
const currentProvider = state?.providers.find((p) => p.isCurrent);
|
||||
const detectedRows = React.useMemo(() => {
|
||||
@@ -889,6 +950,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 +1125,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 +1141,10 @@ function App() {
|
||||
} catch (e) {
|
||||
if (!quiet) setError(String(e));
|
||||
} finally {
|
||||
if (!quiet) setLoading(false);
|
||||
setActionBusy("");
|
||||
if (!quiet) {
|
||||
setLoading(false);
|
||||
setActionBusy("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1246,10 +1314,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 +1567,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 +1670,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>
|
||||
@@ -1542,10 +1717,14 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state ? (
|
||||
<div className="panel glass center-panel">
|
||||
<Loader2 className="spin" />
|
||||
{(!state || bootVisible) ? (
|
||||
<div className={cx("panel glass center-panel boot-panel", bootLeaving && "leaving")}>
|
||||
<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 +2089,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,237 @@ 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.leaving {
|
||||
pointer-events: none;
|
||||
animation: boot-panel-out .28s ease-in 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-panel-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
to { opacity: 0; transform: translateY(-8px) scale(.992); filter: blur(4px); }
|
||||
}
|
||||
@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.20",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Reference in New Issue
Block a user