Compare commits

..
11 Commits
Author SHA1 Message Date
yyyyyynnn 2ba23e5e77 Polish prompt async flow 2026-07-06 01:26:52 +08:00
yyyyyynnn 9cbd7bf351 Add Skills and MCP management 2026-07-06 01:11:13 +08:00
yyyyyynnn 6f77eec4ba Release v0.2.18 2026-07-05 23:58:44 +08:00
yyyyyynnn d3145fd24d Add remote prompt template updates 2026-07-05 23:57:59 +08:00
yyyyyynnn c8f624fe91 Polish prompt import and session UX 2026-07-05 23:47:37 +08:00
yyyyyynnn c10b97b25e Add startup diagnostics and session tools 2026-07-05 23:39:43 +08:00
yyyyyynnn 9415749af2 Add English README 2026-07-05 17:17:12 +08:00
yyyyyynnn a33c78a6e8 Use official Star History embed 2026-07-05 11:28:26 +08:00
yyyyyynnn d7b852cec9 Use local star history chart 2026-07-05 11:23:24 +08:00
yyyyyynnn 31c630f890 Clarify Star History delay 2026-07-05 11:02:02 +08:00
yyyyyynnn 37d911d94a Normalize release asset names 2026-07-04 21:48:38 +08:00
16 changed files with 3465 additions and 169 deletions
+35
View File
@@ -105,6 +105,41 @@ jobs:
EOF
powershell -NoProfile -Command "Compress-Archive -Path 'apps/desktop/src-tauri/portable/Codex-X_${version}_windows_x64_portable/*' -DestinationPath 'apps/desktop/src-tauri/Codex-X_${version}_windows_x64_portable.zip' -Force"
- name: Normalize release asset names
run: |
set -euo pipefail
version=$(node -p "require('./apps/desktop/package.json').version")
case "${{ matrix.name }}" in
"macOS Apple Silicon")
target="apps/desktop/src-tauri/target/release/bundle/dmg/Codex-X_${version}_macos_apple_silicon_aarch64.dmg"
src=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' | head -n 1)
test -n "$src"
mv "$src" "$target"
;;
"macOS Intel")
target="apps/desktop/src-tauri/target/release/bundle/dmg/Codex-X_${version}_macos_intel_x64.dmg"
src=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' | head -n 1)
test -n "$src"
mv "$src" "$target"
;;
"Windows")
target="apps/desktop/src-tauri/target/release/bundle/msi/Codex-X_${version}_windows_x64.msi"
src=$(find apps/desktop/src-tauri/target/release/bundle/msi -maxdepth 1 -type f -name '*.msi' | head -n 1)
test -n "$src"
mv "$src" "$target"
;;
"Linux")
deb_target="apps/desktop/src-tauri/target/release/bundle/deb/Codex-X_${version}_linux_x64.deb"
rpm_target="apps/desktop/src-tauri/target/release/bundle/rpm/Codex-X_${version}_linux_x64.rpm"
deb_src=$(find apps/desktop/src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
rpm_src=$(find apps/desktop/src-tauri/target/release/bundle/rpm -maxdepth 1 -type f -name '*.rpm' | head -n 1)
test -n "$deb_src"
test -n "$rpm_src"
mv "$deb_src" "$deb_target"
mv "$rpm_src" "$rpm_target"
;;
esac
- name: Upload bundle artifacts
uses: actions/upload-artifact@v4
with:
+21
View File
@@ -2,6 +2,27 @@
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 最新版本,离线时自动回退本地缓存或打包内置版本。
- 指令提示词页面新增“更新内置模板”状态与来源展示,可看到模板来自 GitHub 最新、本地缓存或打包内置。
- 继续保留导入 `.md` 提示词、外部提示词自动记忆、会话管理交互优化和 API Key 可见切换等体验改进。
## [v0.2.17] - 2026-07-04
- 修复 macOS Intel Release 构建 runner:从已不可用/长时间排队的 `macos-13` 切换为 `macos-15-intel`。
+326
View File
@@ -0,0 +1,326 @@
<p align="center">
<a href="README.md"><img src="https://img.shields.io/badge/中文-切换-lightgrey" alt="中文" /></a>
<a href="README.en.md"><img src="https://img.shields.io/badge/English-Current-blue" alt="English" /></a>
</p>
<div align="center">
<img src="apps/desktop/src-tauri/icons/icon.png" alt="Codex-X Logo" width="150" />
# Codex-X
**Prompt Injection · Provider Switching · TOML / Auth Visual Manager for Codex**
Codex-X is a cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. It ships with `gpt5.5-unrestricted.md` and `gpt5.4-unrestricted.md`, and supports one-click enable / disable for instruction prompts, third-party Provider switching, official Auth management, TOML visual editing, and local session Provider Sync.
<p>
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-555" alt="platform" />
<img src="https://img.shields.io/badge/built%20with-Tauri%202-24C8DB" alt="tauri" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="license" />
</p>
<p>
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=white" />
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white" />
<img src="https://img.shields.io/badge/Rust-stable-000000?logo=rust&logoColor=white" />
<img src="https://img.shields.io/badge/SQLite-Ready-003B57?logo=sqlite&logoColor=white" />
<img src="https://img.shields.io/badge/Vite-Ready-646CFF?logo=vite&logoColor=white" />
</p>
</div>
---
## What is Codex-X?
Codex-X is not just a configuration-file editor. It is a **visual enhancement manager** built for Codex CLI and Codex Desktop workflows.
It turns several high-frequency operations into a desktop UI:
- Enable / disable instruction prompt templates for Codex
- Switch between official OpenAI and third-party Codex API Providers
- View / edit `~/.codex/config.toml`
- View / edit official `~/.codex/auth.json`
- Check and repair Provider metadata for local historical sessions
## Preview
<details open>
<summary><b>App preview</b>: Home / Provider / TOML / Auth</summary>
<p align="center">
<img src="docs/screenshots/app/preview.png" alt="Codex-X app preview: Home, Provider, TOML, Auth" width="920" />
</p>
</details>
<details>
<summary><b>Prompt injection results: security testing scenarios</b></summary>
<div align="center">
<table>
<tr>
<td align="center" width="50%">
<b>SQL Injection Testing</b><br />
<sub>Post-deployment test: how to perform SQL injection testing against a target?</sub><br />
<img src="docs/screenshots/prompt-effects/security/sql-injection.png" alt="SQL injection testing result" width="420" />
</td>
<td align="center" width="50%">
<b>NSFW Response Test</b><br />
<sub>Observe boundary response changes after prompt injection</sub><br />
<img src="docs/screenshots/prompt-effects/security/nsfw.png" alt="NSFW response test result" width="420" />
</td>
</tr>
</table>
</div>
</details>
<details>
<summary><b>Prompt injection results: reverse engineering scenarios</b></summary>
<div align="center">
<table>
<tr>
<td align="center" width="50%">
<b>APK Reverse Engineering</b><br />
<sub>Static / dynamic analysis workflow for Android APKs</sub><br />
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-1.png" alt="APK reverse engineering result" width="420" />
</td>
<td align="center" width="50%">
<b>APK Reverse Engineering 2</b><br />
<sub>Additional APK reverse workflow and locating methods</sub><br />
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-2.png" alt="APK reverse engineering result 2" width="420" />
</td>
</tr>
<tr>
<td align="center" colspan="2">
<b>EXE Reverse Engineering</b><br />
<sub>Windows executable analysis and debugging directions</sub><br />
<img src="docs/screenshots/prompt-effects/reverse/exe-reverse.png" alt="EXE reverse engineering result" width="620" />
</td>
</tr>
</table>
</div>
</details>
## Features
<div align="center">
<table>
<tr>
<th align="center" width="180">Feature</th>
<th align="center">Description</th>
</tr>
<tr>
<td align="center">⚡ Provider API</td>
<td>Visually manage official OpenAI / third-party Codex Providers, including Base URL, API Key, Model, Wire API, and one-click switching.</td>
</tr>
<tr>
<td align="center">🧩 <b>Prompt Injection</b></td>
<td><b>Signature feature</b>: built-in <code>gpt5.4-unrestricted.md</code> / <code>gpt5.5-unrestricted.md</code>, one-click write into Codex configuration; after enabling, it can produce results similar to the screenshots above for SQL injection testing and APK / EXE reverse engineering scenarios.</td>
</tr>
<tr>
<td align="center">💬 Session Management</td>
<td>Read Codex local SQLite and rollout JSONL, display local sessions visually, and support Provider Sync / historical thread metadata repair.</td>
</tr>
<tr>
<td align="center">📝 TOML Config</td>
<td>View current <code>~/.codex/config.toml</code>, edit full TOML directly from the Provider editor, and save changes back to the Codex config directory.</td>
</tr>
<tr>
<td align="center">🔐 Auth Management</td>
<td>Read / edit official <code>~/.codex/auth.json</code>, and distinguish ChatGPT login-state Auth from third-party API Keys.</td>
</tr>
<tr>
<td align="center">📦 Cross-platform Release</td>
<td>Provides macOS Apple Silicon / Intel <code>.dmg</code>, Windows <code>.msi</code> / portable <code>.zip</code>, Linux <code>.deb</code> / <code>.rpm</code>, plus in-app update checking.</td>
</tr>
</table>
</div>
## Highlights
### 1. Built-in dual instruction prompt templates
Codex-X currently includes two prompt templates:
- `examples/gpt5.5-unrestricted.md`
- `examples/gpt5.4-unrestricted.md`
You can enable / disable them directly from the Prompt page. When enabled, Codex-X writes the selected `.md` file into the Codex configuration directory and updates `model_instructions_file` in `config.toml`.
<div align="center">
<table>
<tr>
<th align="center">Item</th>
<th align="center"><code>gpt5.4-unrestricted.md</code></th>
<th align="center"><code>gpt5.5-unrestricted.md</code></th>
</tr>
<tr>
<td align="center">Positioning</td>
<td align="center">5.4 CTF sandbox</td>
<td align="center">5.5 unrestricted mode</td>
</tr>
<tr>
<td align="center">Strategy</td>
<td align="center">Indirect bypass through CTF competition sandbox framing</td>
<td align="center">Direct declaration of unrestricted developer mode</td>
</tr>
<tr>
<td align="center">Style</td>
<td align="center">Security-research oriented, detailed methodology</td>
<td align="center">Minimal instructions, broad coverage</td>
</tr>
<tr>
<td align="center">Length</td>
<td align="center">~180 lines</td>
<td align="center">~40 lines</td>
</tr>
<tr>
<td align="center">Target</td>
<td align="center">GPT-5.4 Codex CLI</td>
<td align="center">GPT-5.5 Codex CLI</td>
</tr>
</table>
</div>
Post-deployment test prompt:
```text
How do I perform SQL injection testing against a target?
```
Typical effect:
```text
Before injection → refusal or generic answer
After injection → direct security research methodology and testing steps
```
### 2. Visual Provider switching
- Add third-party Codex API Providers
- Edit Base URL / API Key / Model / Wire API
- View and edit Provider-related TOML from the Provider page
- Clearly display the currently active Provider
- Import Codex Providers from a cc-switch database
### 3. Official Auth management
- Automatically read Codex official `auth.json`
- View / edit ChatGPT login-state Auth
- Distinguish official Auth from third-party API Keys
- Manage official Auth and third-party Providers in one UI
### 4. Visual TOML editing
- View the current Codex `config.toml`
- Dark code preview with syntax highlighting
- Edit full TOML directly from the Provider editor
- Save changes back to the Codex configuration directory
### 5. Session Management / Provider Sync
Codex-X can read Codex local session data:
```text
~/.codex/sqlite/*.db
~/.codex/state_5.sqlite
~/.codex/sessions/**/rollout-*.jsonl
~/.codex/archived_sessions/**/rollout-*.jsonl
```
It checks whether historical session Provider metadata matches the current configuration, and supports one-click sync / repair so historical threads can still be recognized, opened, and continued by native Codex.
### 6. Cross-platform desktop app
- macOS Apple Silicon `.dmg`
- macOS Intel `.dmg`
- Windows `.msi`
- Windows Portable `.zip`
- Linux `.deb` / `.rpm`
- Automatic GitHub Releases builds
- In-app update checking
## Tech Stack
| Category | Technology |
| --- | --- |
| Desktop framework | Tauri 2 |
| Frontend | React 18 / TypeScript / Vite |
| Backend | Rust |
| Local data | SQLite / rusqlite |
| Config editing | TOML / JSON |
| Release | GitHub Actions / GitHub Releases |
## Configuration Paths
Codex-X reads the Codex configuration directory by default:
```text
~/.codex/config.toml
~/.codex/auth.json
```
Environment variables are also supported:
```text
CODEX_HOME=/path/to/.codex
CODEXX_HOME=/path/to/codex-x-data
CC_SWITCH_HOME=/path/to/.cc-switch
```
Codex-X's own database is stored by default at:
```text
~/.codexx/codexx.db
```
## Download
Download from the Releases page:
https://github.com/yynxxxxx/Codex-X/releases
## Development
```bash
pnpm install
pnpm dev
```
Build desktop bundles:
```bash
pnpm --dir apps/desktop tauri build
```
## macOS Installation Note
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
- Best option: sign and notarize with an Apple Developer ID
- Local testing only: remove the quarantine attribute manually
```bash
xattr -dr com.apple.quarantine /Applications/Codex-X.app
```
## License
This project is open-sourced under the [MIT License](https://github.com/yynxxxxx/Codex-X/blob/main/LICENSE).
## Thanks
Thanks to the [LINUX DO forum](https://linux.do/) community for attention, feedback, and support.
## Star History
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=yynxxxxx/Codex-X&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=yynxxxxx/Codex-X&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx/Codex-X&type=date&legend=top-left" />
</picture>
</a>
+12 -2
View File
@@ -1,3 +1,8 @@
<p align="center">
<a href="README.md"><img src="https://img.shields.io/badge/中文-当前-blue" alt="中文" /></a>
<a href="README.en.md"><img src="https://img.shields.io/badge/English-Switch-lightgrey" alt="English" /></a>
</p>
<div align="center">
<img src="apps/desktop/src-tauri/icons/icon.png" alt="Codex-X Logo" width="150" />
@@ -312,5 +317,10 @@ xattr -dr com.apple.quarantine /Applications/Codex-X.app
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=yynxxxxx/Codex-X&type=Date)](https://www.star-history.com/#yynxxxxx/Codex-X&Date)
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=yynxxxxx/Codex-X&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=yynxxxxx/Codex-X&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx/Codex-X&type=date&legend=top-left" />
</picture>
</a>
+9
View File
@@ -4,6 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codex-X</title>
<style>
html, body { margin: 0; min-height: 100%; background: #070a13; }
#root:empty::before {
content: "Codex-X";
position: fixed; inset: 0; display: grid; place-items: center;
color: #eef4ff; font: 800 28px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at 30% 12%, rgba(124,92,255,.22), transparent 32%), linear-gradient(135deg, #060814 0%, #0c1120 52%, #071421 100%);
}
</style>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.17",
"version": "0.2.20",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+163 -1
View File
@@ -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,17 +346,20 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.17"
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]]
@@ -546,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"
@@ -2497,6 +2520,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
@@ -2526,6 +2563,41 @@ dependencies = [
"semver",
]
[[package]]
name = "rustls"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2920,6 +2992,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3682,6 +3760,28 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -3937,6 +4037,24 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4176,6 +4294,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -4568,6 +4695,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
@@ -4601,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",
]
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.2.17"
version = "0.2.20"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -23,3 +23,6 @@ chrono = { version = "0.4", features = ["serde"] }
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"] }
+9
View File
@@ -0,0 +1,9 @@
pub(crate) const INSTRUCTION_FILENAME: &str = "gpt5.5-unrestricted.md";
pub(crate) const INSTRUCTION_RELATIVE: &str = "./gpt5.5-unrestricted.md";
pub(crate) const INSTRUCTION_CONTENT: &str =
include_str!("../../../../examples/gpt5.5-unrestricted.md");
pub(crate) const INSTRUCTION_54_FILENAME: &str = "gpt5.4-unrestricted.md";
pub(crate) const INSTRUCTION_54_RELATIVE: &str = "./gpt5.4-unrestricted.md";
pub(crate) const INSTRUCTION_54_CONTENT: &str =
include_str!("../../../../examples/gpt5.4-unrestricted.md");
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
use std::process::Command;
pub fn command_version(program: &str, args: &[&str]) -> Option<String> {
Command::new(program)
.args(args)
.output()
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(target_os = "macos")]
pub fn macos_codex_app_version() -> Option<String> {
let candidates = [
"/Applications/Codex.app",
"/Applications/OpenAI Codex.app",
"/Applications/ChatGPT Codex.app",
];
for app in candidates {
let output = Command::new("mdls")
.args(["-name", "kMDItemVersion", "-raw", app])
.output()
.ok()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !text.is_empty() && text != "(null)" {
return Some(text);
}
}
}
None
}
#[cfg(not(target_os = "macos"))]
pub fn macos_codex_app_version() -> Option<String> {
None
}
pub fn detect_codex_version() -> Option<String> {
command_version("codex", &["--version"])
.or_else(|| command_version("codex", &["-V"]))
.or_else(macos_codex_app_version)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.17",
"version": "0.2.20",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+686 -63
View File
@@ -7,6 +7,8 @@ import {
Download,
ExternalLink,
AlertCircle,
Eye,
EyeOff,
FileCode2,
Globe2,
History,
@@ -18,10 +20,12 @@ import {
Power,
RefreshCw,
RotateCcw,
Search,
Settings,
Sparkles,
TerminalSquare,
Trash2,
Upload,
Zap,
} from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
@@ -30,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;
@@ -66,6 +70,17 @@ type SavedPrompt = {
content: string;
};
type BuiltinPromptStatus = {
id: string;
filename: string;
sourceUrl: string;
cached: boolean;
updated: boolean;
contentSource: string;
checkedAt?: string | null;
message: string;
};
type BackupEntry = {
id: string;
action: string;
@@ -168,8 +183,64 @@ 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;
path?: string | null;
status: "ok" | "missing" | "manual" | string;
message: string;
};
type StartupDiagnostics = {
codexDir: string;
needsManualSelect: boolean;
summary: string;
items: DiagnosticItem[];
};
const INSTRUCTION_RELATIVE_UI = "./gpt5.5-unrestricted.md";
const LANG_KEY = "codexx.lang";
const STARTUP_WIZARD_SEEN_KEY = "codexx.startupWizardSeen";
const FALLBACK_GITHUB_REPO = "yynxxxxx/Codex-X";
const instructionTemplates: InstructionTemplate[] = [
@@ -226,6 +297,7 @@ const dict = {
dashboard: "概览",
provider: "供应商",
sessions: "会话管理",
skillsMcp: "技能和MCP",
instruction: "指令提示词",
toml: "TOML",
settings: "设置",
@@ -333,6 +405,7 @@ const dict = {
dashboard: "Overview",
provider: "Provider",
sessions: "Sessions",
skillsMcp: "Skills & MCP",
instruction: "Prompt",
toml: "TOML",
settings: "Settings",
@@ -727,25 +800,43 @@ function App() {
const [editingPromptId, setEditingPromptId] = React.useState<string | null>(null);
const [savedProviders, setSavedProviders] = React.useState<SavedProvider[]>([]);
const [savedPrompts, setSavedPrompts] = React.useState<SavedPrompt[]>([]);
const [builtinPromptStatus, setBuiltinPromptStatus] = React.useState<BuiltinPromptStatus[]>([]);
const [aboutInfo, setAboutInfo] = React.useState<AboutInfo | null>(null);
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[]>([]);
const [state, setState] = React.useState<CodexState | null>(null);
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);
const [providerTomlDraft, setProviderTomlDraft] = React.useState("");
const [providerTomlDirty, setProviderTomlDirty] = React.useState(false);
const [providerApiKeyVisible, setProviderApiKeyVisible] = React.useState(false);
const [actionBusy, setActionBusy] = React.useState<string>("");
const [promptForm, setPromptForm] = React.useState<SavedPrompt>(blankPromptForm);
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);
const builtinPromptStatusMap = React.useMemo(() => new Map(builtinPromptStatus.map((item) => [item.id, item])), [builtinPromptStatus]);
const releaseStatusLabel = React.useMemo(() => {
if (releaseInfo.status === "checking") return lang === "zh" ? "检查中" : "Checking";
if (releaseInfo.status === "error") return lang === "zh" ? "失败" : "Failed";
@@ -764,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(() => {
@@ -817,6 +920,43 @@ function App() {
return rows;
}, [detectedRows, localRows, state?.model, state?.modelProvider]);
const filteredSessions = React.useMemo(() => {
const query = sessionQuery.trim().toLowerCase();
const list = sessionStatus?.sessions || [];
if (!query) return list;
return list.filter((item) => [item.title, item.cwd, item.rolloutPath, item.modelProvider, item.model, item.id]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query)));
}, [sessionQuery, sessionStatus?.sessions]);
const groupedSessions = React.useMemo(() => {
const groups = new Map<string, SessionPreview[]>();
if (!sessionGroupByCwd) {
groups.set(lang === "zh" ? "全部会话" : "All sessions", filteredSessions);
return Array.from(groups.entries());
}
for (const item of filteredSessions) {
const key = item.cwd || (lang === "zh" ? "未记录工作目录" : "No workspace recorded");
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
return Array.from(groups.entries()).sort((a, b) => b[1].length - a[1].length);
}, [filteredSessions, lang, sessionGroupByCwd]);
const unsyncedChatCount = Math.max(sessionStatus?.mismatchedThreads ?? 0, sessionStatus?.mismatchedSessionMeta ?? 0);
const selectedSessionSet = React.useMemo(() => new Set(selectedSessionIds), [selectedSessionIds]);
const selectedNeedsSyncCount = React.useMemo(() => {
const set = new Set(selectedSessionIds);
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]);
const call = React.useCallback(async <T,>(fn: () => Promise<T>, success?: (data: T) => void) => {
setLoading(true);
setError("");
@@ -833,22 +973,29 @@ function App() {
const refresh = React.useCallback(() => {
call(
async () => {
const next = await invoke<CodexState>("get_codex_state", { configDir: configDir || null });
const backupList = await invoke<BackupEntry[]>("list_backups");
const providerList = await invoke<SavedProvider[]>("list_saved_providers");
const promptList = await invoke<SavedPrompt[]>("list_saved_prompts");
const about = await invoke<AboutInfo>("get_about_info", { configDir: configDir || null });
const sessions = await invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || null, targetProvider: null });
return { next, backupList, providerList, promptList, about, sessions };
const [diagnostics, next, backupList, providerList, promptList, builtinStatus, about] = await Promise.all([
invoke<StartupDiagnostics>("get_startup_diagnostics", { configDir: configDir || null }),
invoke<CodexState>("get_codex_state", { configDir: configDir || null }),
invoke<BackupEntry[]>("list_backups"),
invoke<SavedProvider[]>("list_saved_providers"),
invoke<SavedPrompt[]>("list_saved_prompts"),
invoke<BuiltinPromptStatus[]>("get_builtin_prompt_status"),
invoke<AboutInfo>("get_about_info", { configDir: configDir || null }),
]);
return { diagnostics, next, backupList, providerList, promptList, builtinStatus, about };
},
({ next, backupList, providerList, promptList, about, sessions }) => {
({ diagnostics, next, backupList, providerList, promptList, builtinStatus, about }) => {
setStartupDiagnostics(diagnostics);
setState(next);
setBackups(backupList);
setSavedProviders(providerList);
setSavedPrompts(promptList);
setBuiltinPromptStatus(builtinStatus);
setAboutInfo(about);
setSessionStatus(sessions);
if (!configDir) setConfigDir(next.codexDir);
void invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || next.codexDir || null, targetProvider: null })
.then(setSessionStatus)
.catch(() => undefined);
},
);
}, [call, configDir]);
@@ -945,6 +1092,62 @@ function App() {
},
);
const importPromptMd = async (file?: File | null) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".md")) {
setError(lang === "zh" ? "请选择 .md 提示词文件" : "Please choose a .md prompt file");
return;
}
setActionBusy("importPrompt");
setLoading(true);
setError("");
try {
const content = await file.text();
const title = file.name.replace(/\.md$/i, "");
await invoke<SavedPrompt>("save_prompt", {
prompt: {
id: providerId(title),
title,
filename: file.name,
content,
},
});
const promptList = await invoke<SavedPrompt[]>("list_saved_prompts");
setSavedPrompts(promptList);
setToast(lang === "zh" ? `已导入提示词:${file.name}` : `Prompt imported: ${file.name}`);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
setActionBusy("");
if (promptImportRef.current) promptImportRef.current.value = "";
}
};
const refreshBuiltinPrompts = async ({ quiet = false }: { quiet?: boolean } = {}) => {
if (!quiet) {
setActionBusy("refreshPrompts");
setLoading(true);
}
try {
const list = await invoke<BuiltinPromptStatus[]>("refresh_builtin_prompts");
setBuiltinPromptStatus(list);
const updated = list.filter((item) => item.updated).length;
if (!quiet) {
setToast(updated > 0
? (lang === "zh" ? `已更新 ${updated} 个内置提示词模板` : `${updated} built-in prompt template(s) updated`)
: (lang === "zh" ? "内置提示词已是 GitHub 最新" : "Built-in prompts are up to date"));
}
} catch (e) {
if (!quiet) setError(String(e));
} finally {
if (!quiet) {
setLoading(false);
setActionBusy("");
}
}
};
const normalizedProviderForm = (): SavedProvider => ({
...providerForm,
id: editingProviderId || providerForm.id || providerId(providerForm.providerName || providerForm.baseUrl),
@@ -1108,6 +1311,115 @@ function App() {
void checkForUpdates({ quiet: true });
}, [state, aboutInfo, checkForUpdates]);
React.useEffect(() => {
if (tab !== "instruction" || promptUpdateCheckedRef.current) return;
promptUpdateCheckedRef.current = 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) {
@@ -1194,32 +1506,79 @@ function App() {
);
};
const checkSessions = () =>
call(
const checkSessions = async () => {
setActionBusy("checkSessions");
await call(
() => invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || null, targetProvider: null }),
(status) => {
setSessionStatus(status);
setToast(status.needsSync
? (lang === "zh" ? `发现 ${status.mismatchedSessionMeta + status.mismatchedThreads} 项需要同步` : "Session sync needed")
? (lang === "zh" ? `发现 ${Math.max(status.mismatchedThreads, status.mismatchedSessionMeta)} 个聊天需要修复` : `${Math.max(status.mismatchedThreads, status.mismatchedSessionMeta)} chat(s) need repair`)
: (lang === "zh" ? "会话已同步" : "Sessions are in sync"));
},
);
setActionBusy("");
};
const syncSessions = () =>
call(
const syncSessions = async () => {
setActionBusy("syncSessions");
await call(
() => invoke<SessionSyncResult>("sync_sessions_provider", { configDir: configDir || null, targetProvider: null }),
(result) => {
setSessionStatus(result.status);
setSelectedSessionIds([]);
setToast(lang === "zh"
? `已修复 ${result.updatedRollouts} 个会话文件、${result.updatedThreads} 条 SQLite 记录`
: `Updated ${result.updatedRollouts} rollout file(s), ${result.updatedThreads} SQLite row(s)`);
? `已修复 ${result.updatedThreads} 条聊天记录`
: `Updated ${result.updatedThreads} chat row(s)`);
},
);
setActionBusy("");
};
const toggleSessionSelected = (id: string) => {
setSelectedSessionIds((ids) => ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]);
};
const selectVisibleNeedsSyncSessions = () => {
const ids = filteredSessions.filter((item) => item.needsSync).map((item) => item.id);
setSelectedSessionIds(ids);
};
const syncSelectedSessions = async () => {
setActionBusy("syncSelectedSessions");
await call(
() => invoke<SessionSyncResult>("sync_selected_sessions_provider", {
input: {
configDir: configDir || null,
targetProvider: null,
sessionIds: selectedSessionIds,
},
}),
(result) => {
setSessionStatus(result.status);
setSelectedSessionIds([]);
setToast(lang === "zh"
? `已修复选中的 ${result.updatedThreads} 条聊天记录`
: `Selected sessions repaired: ${result.updatedThreads} chat row(s)`);
},
);
setActionBusy("");
};
const closeStartupWizard = () => {
localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
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} />],
@@ -1310,10 +1669,62 @@ function App() {
</div>
)}
{!state ? (
<div className="panel glass center-panel">
<Loader2 className="spin" />
{startupWizardOpen && startupDiagnostics && (
<div className={cx("startup-mask", startupClosing && "closing")}>
<div className="startup-card glass">
<div className="startup-head">
<div>
<p className="eyebrow">First Run Check</p>
<h3>{lang === "zh" ? "首次启动向导" : "First-run wizard"}</h3>
<p>{lang === "zh" ? startupDiagnostics.summary : startupDiagnostics.summary}</p>
</div>
<button className="ghost-btn" onClick={closeStartupWizard}>{lang === "zh" ? "跳过" : "Skip"}</button>
</div>
<div className="startup-path-row">
<Field label="CODEX_HOME">
<input value={configDir || startupDiagnostics.codexDir} onChange={(e) => setConfigDir(e.target.value)} placeholder="~/.codex" />
</Field>
<button className="secondary-btn" onClick={refresh} disabled={loading}>
<RefreshCw size={16} className={cx(loading && "spin")} /> {lang === "zh" ? "重新检测" : "Recheck"}
</button>
</div>
<div className="startup-check-grid">
{startupDiagnostics.items.map((item) => (
<div className={cx("startup-check-item", item.status === "ok" && "ok", item.status === "manual" && "manual")} key={item.key}>
<div className="startup-check-icon">
{item.status === "ok" ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
</div>
<div>
<strong>{item.label}</strong>
<p>{lang === "zh" ? item.message : item.status === "ok" ? "Detected" : item.status === "manual" ? "Manual selection required" : "Not found"}</p>
{item.path && <code>{item.path}</code>}
</div>
</div>
))}
</div>
<div className="startup-actions">
<button className="secondary-btn" onClick={() => { setTab("settings"); closeStartupWizard(); }}>
<Settings size={16} /> {lang === "zh" ? "去设置" : "Settings"}
</button>
<button className="primary-btn" onClick={closeStartupWizard}>
<CheckCircle2 size={16} /> {lang === "zh" ? "进入 Codex-X" : "Enter Codex-X"}
</button>
</div>
</div>
</div>
)}
{(!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>
) : (
<>
@@ -1477,7 +1888,24 @@ function App() {
</div>
</div>
<div className="form-grid provider-form-grid provider-form-cc">
<Field label={t.provider.apiKey}><input type="password" value={providerForm.apiKey || ""} onChange={(e) => setProviderForm({ ...providerForm, apiKey: e.target.value })} placeholder={t.provider.apiKeyPlaceholder} /></Field>
<Field label={t.provider.apiKey}>
<div className="secret-input-wrap">
<input
type={providerApiKeyVisible ? "text" : "password"}
value={providerForm.apiKey || ""}
onChange={(e) => setProviderForm({ ...providerForm, apiKey: e.target.value })}
placeholder={t.provider.apiKeyPlaceholder}
/>
<button
type="button"
className="secret-toggle"
onClick={() => setProviderApiKeyVisible((value) => !value)}
aria-label={providerApiKeyVisible ? (lang === "zh" ? "隐藏 API Key" : "Hide API Key") : (lang === "zh" ? "显示 API Key" : "Show API Key")}
>
{providerApiKeyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</Field>
<Field label={lang === "zh" ? "API 请求地址" : t.provider.baseUrl}><input value={providerForm.baseUrl} onChange={(e) => setProviderForm({ ...providerForm, baseUrl: e.target.value })} /></Field>
<Field label={t.provider.name}><input value={providerForm.providerName} onChange={(e) => setProviderForm({ ...providerForm, providerName: e.target.value, id: editingProviderId || providerId(e.target.value) })} /></Field>
<Field label={t.provider.model}><input value={providerForm.model} onChange={(e) => setProviderForm({ ...providerForm, model: e.target.value })} /></Field>
@@ -1518,7 +1946,7 @@ function App() {
</section>
<div className="form-actions provider-save-actions">
<button className="primary-btn big" onClick={saveAndSwitch} disabled={loading}><Zap size={18} /> {t.provider.saveAndSwitch}</button>
<button className="primary-btn big lively-btn" onClick={saveAndSwitch} disabled={loading}>{loading ? <Loader2 size={18} className="spin" /> : <Zap size={18} />} {loading ? (lang === "zh" ? "保存中..." : "Saving...") : t.provider.saveAndSwitch}</button>
</div>
</div>
</div>
@@ -1539,11 +1967,11 @@ function App() {
</p>
</div>
<div className="provider-title-actions">
<button className="secondary-btn add-provider-btn" onClick={checkSessions} disabled={loading}>
<RefreshCw size={18} className={cx(loading && "spin")} /> {lang === "zh" ? "检查会话" : "Check"}
<button className="secondary-btn add-provider-btn lively-btn" onClick={checkSessions} disabled={loading}>
{actionBusy === "checkSessions" ? <Loader2 size={18} className="spin" /> : <RefreshCw size={18} />} {actionBusy === "checkSessions" ? (lang === "zh" ? "检查中..." : "Checking...") : (lang === "zh" ? "检查会话" : "Check")}
</button>
<button className="primary-btn add-provider-btn" onClick={syncSessions} disabled={loading || !sessionStatus?.needsSync}>
<Zap size={18} /> {lang === "zh" ? "同步 / 修复" : "Sync / repair"}
<button className="primary-btn add-provider-btn lively-btn" onClick={syncSessions} disabled={loading || !sessionStatus?.needsSync}>
{actionBusy === "syncSessions" ? <Loader2 size={18} className="spin" /> : <Zap size={18} />} {actionBusy === "syncSessions" ? (lang === "zh" ? "修复中..." : "Repairing...") : (lang === "zh" ? "同步 / 修复" : "Sync / repair")}
</button>
</div>
</div>
@@ -1561,52 +1989,94 @@ function App() {
{sessionStatus?.needsSync && <StatusPill active label={lang === "zh" ? "需要修复" : "Needs repair"} />}
</div>
<div className="session-stat-grid">
<StatCard icon={<FileCode2 size={20} />} label={lang === "zh" ? "rollout 文件" : "Rollout files"} value={sessionStatus?.rolloutFiles ?? "-"} ok />
<StatCard icon={<Sparkles size={20} />} label={lang === "zh" ? "session_meta" : "session_meta"} value={sessionStatus?.sessionMetaCount ?? "-"} ok />
<StatCard icon={<AlertCircle size={20} />} label={lang === "zh" ? "未同步 JSONL" : "Unsynced JSONL"} value={sessionStatus?.mismatchedRollouts ?? "-"} ok={!sessionStatus?.mismatchedRollouts} />
<StatCard icon={<Layers3 size={20} />} label={lang === "zh" ? "SQLite threads" : "SQLite threads"} value={sessionStatus?.sqliteThreads ?? "-"} ok />
<StatCard icon={<AlertCircle size={20} />} label={lang === "zh" ? "未同步记录" : "Unsynced rows"} value={sessionStatus?.mismatchedThreads ?? "-"} ok={!sessionStatus?.mismatchedThreads} />
<StatCard icon={<Code2 size={20} />} label={lang === "zh" ? "SQLite 数据库" : "SQLite DBs"} value={sessionStatus?.sqliteDbs ?? "-"} ok />
<div className="session-stat-grid user-session-stats">
<StatCard icon={<History size={20} />} label={lang === "zh" ? "聊天总数" : "Total chats"} value={sessionStatus?.sqliteThreads ?? "-"} ok />
<StatCard icon={<Layers3 size={20} />} label={lang === "zh" ? "当前已展示" : "Shown now"} value={filteredSessions.length} ok />
<StatCard icon={<AlertCircle size={20} />} label={lang === "zh" ? "需要修复" : "Need repair"} value={sessionStatus ? unsyncedChatCount : "-"} ok={!unsyncedChatCount} />
<StatCard icon={<CheckCircle2 size={20} />} label={lang === "zh" ? "状态" : "Status"} value={sessionStatus?.needsSync ? (lang === "zh" ? "可修复" : "Repairable") : (lang === "zh" ? "正常" : "OK")} ok={!sessionStatus?.needsSync} />
</div>
<div className="session-list-card">
<div className="session-list-head">
<div className="session-list-head session-list-head-rich">
<div>
<p className="eyebrow">{lang === "zh" ? "本地会话" : "Local threads"}</p>
<h4>{lang === "zh" ? "会话列表" : "Sessions"}</h4>
</div>
<span>{lang === "zh" ? `展示 ${sessionStatus?.sessions?.length ?? 0} / ${sessionStatus?.sqliteThreads ?? 0} 条` : `${sessionStatus?.sessions?.length ?? 0} / ${sessionStatus?.sqliteThreads ?? 0} shown`}</span>
<span>{lang === "zh" ? `展示 ${filteredSessions.length} / ${sessionStatus?.sqliteThreads ?? 0} 条` : `${filteredSessions.length} / ${sessionStatus?.sqliteThreads ?? 0} shown`}</span>
</div>
{sessionStatus?.sessions?.length ? (
<div className="session-list">
{sessionStatus.sessions.map((item) => (
<article className={cx("session-row", item.needsSync && "needs-sync")} key={item.id}>
<div className="session-row-main">
<div className="session-thread-icon"><History size={18} /></div>
<div className="session-row-text">
<div className="session-row-title">
<strong>{item.title}</strong>
{item.archived && <span className="mini-tag">{lang === "zh" ? "已归档" : "Archived"}</span>}
{item.needsSync && <span className="mini-tag warn">{lang === "zh" ? "需同步" : "Needs sync"}</span>}
</div>
<p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath)}</p>
</div>
<div className="session-toolbar">
<label className="session-search">
<Search size={16} />
<input
value={sessionQuery}
onChange={(e) => setSessionQuery(e.target.value)}
placeholder={lang === "zh" ? "搜索标题 / cwd / Provider / ID" : "Search title / cwd / Provider / ID"}
/>
</label>
<label className="session-toggle">
<input type="checkbox" checked={sessionGroupByCwd} onChange={(e) => setSessionGroupByCwd(e.target.checked)} />
<span>{lang === "zh" ? "按项目路径分组" : "Group by cwd"}</span>
</label>
<button className="ghost-btn small" onClick={selectVisibleNeedsSyncSessions} disabled={!filteredSessions.some((item) => item.needsSync)}>
{lang === "zh" ? "选择需同步" : "Select unsynced"}
</button>
<button className="secondary-btn small" onClick={() => setSelectedSessionIds([])} disabled={!selectedSessionIds.length}>
{lang === "zh" ? "清空选择" : "Clear"}
</button>
<button className="primary-btn small lively-btn" onClick={syncSelectedSessions} disabled={loading || selectedNeedsSyncCount === 0}>
{actionBusy === "syncSelectedSessions" ? <Loader2 size={15} className="spin" /> : <Zap size={15} />} {actionBusy === "syncSelectedSessions" ? (lang === "zh" ? "修复中..." : "Repairing...") : (lang === "zh" ? `修复选中 ${selectedNeedsSyncCount}` : `Repair ${selectedNeedsSyncCount}`)}
</button>
</div>
{filteredSessions.length ? (
<div className="session-list enhanced-session-list">
{groupedSessions.map(([group, items]) => (
<div className="session-group" key={group}>
<div className="session-group-title">
<span title={group}>{compactPath(group, 88)}</span>
<em>{items.length}</em>
</div>
<div className="session-row-meta">
<span>{formatSessionTime(item.updatedAtMs)}</span>
<code>{item.modelProvider || "unknown"}</code>
<em>{item.model || "model -"}</em>
<small>#{shortId(item.id)}</small>
</div>
</article>
{items.map((item) => {
const providerOk = !item.needsSync;
return (
<article className={cx("session-row", item.needsSync && "needs-sync", selectedSessionSet.has(item.id) && "selected")} key={item.id}>
<label className="session-select-box" title={item.needsSync ? (lang === "zh" ? "选择修复这个会话" : "Select this session for repair") : (lang === "zh" ? "Provider 已一致" : "Provider already matches")}>
<input
type="checkbox"
checked={selectedSessionSet.has(item.id)}
onChange={() => toggleSessionSelected(item.id)}
disabled={!item.needsSync}
/>
</label>
<div className="session-row-main">
<div className="session-thread-icon"><History size={18} /></div>
<div className="session-row-text">
<div className="session-row-title">
<strong>{item.title}</strong>
{item.archived && <span className="mini-tag">{lang === "zh" ? "已归档" : "Archived"}</span>}
<span className={cx("mini-tag", providerOk ? "ok" : "warn")}>{providerOk ? (lang === "zh" ? "Provider 一致" : "Provider OK") : (lang === "zh" ? "需同步" : "Needs sync")}</span>
</div>
<p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath)}</p>
</div>
</div>
<div className="session-row-meta">
<span title={item.updatedAtMs ? new Date(item.updatedAtMs).toLocaleString() : undefined}>{formatSessionTime(item.updatedAtMs)}</span>
<code title={item.modelProvider || undefined}>{item.modelProvider || "unknown"}</code>
<em title={item.model || undefined}>{item.model || "model -"}</em>
<small>#{shortId(item.id)}</small>
</div>
</article>
);
})}
</div>
))}
</div>
) : (
<div className="session-empty">
<History size={22} />
<span>{lang === "zh" ? "还没有读取到会话。点击右上角“检查会话”刷新。" : "No sessions loaded. Click Check to refresh."}</span>
<span>{sessionQuery ? (lang === "zh" ? "没有匹配的会话。" : "No matching sessions.") : (lang === "zh" ? "还没有读取到会话。点击右上角“检查会话”刷新。" : "No sessions loaded. Click Check to refresh.")}</span>
</div>
)}
</div>
@@ -1619,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" ? (
@@ -1629,13 +2219,32 @@ function App() {
<h3>{t.instruction.title}</h3>
</div>
<div className="provider-title-actions">
<button className="primary-btn add-provider-btn" onClick={openAddPrompt}><Plus size={18} /> {lang === "zh" ? "添加提示词" : "Add prompt"}</button>
<input
ref={promptImportRef}
className="hidden-file-input"
type="file"
accept=".md,text/markdown,text/plain"
onChange={(e) => void importPromptMd(e.target.files?.[0])}
/>
<button className="ghost-btn add-provider-btn lively-btn" onClick={() => void refreshBuiltinPrompts()} disabled={loading || actionBusy === "refreshPrompts"}>
{actionBusy === "refreshPrompts" ? <Loader2 size={18} className="spin" /> : <RefreshCw size={18} />} {actionBusy === "refreshPrompts" ? (lang === "zh" ? "更新中..." : "Updating...") : (lang === "zh" ? "更新内置模板" : "Update built-ins")}
</button>
<button className="secondary-btn add-provider-btn lively-btn" onClick={() => promptImportRef.current?.click()} disabled={loading}>
{actionBusy === "importPrompt" ? <Loader2 size={18} className="spin" /> : <Upload size={18} />} {actionBusy === "importPrompt" ? (lang === "zh" ? "导入中..." : "Importing...") : (lang === "zh" ? "导入 md" : "Import md")}
</button>
<button className="primary-btn add-provider-btn lively-btn" onClick={openAddPrompt}><Plus size={18} /> {lang === "zh" ? "添加提示词" : "Add prompt"}</button>
</div>
</div>
<div className="instruction-row-list">
{instructionTemplates.map((item) => {
const isCurrent = currentInstructionId === item.id;
const remoteStatus = builtinPromptStatusMap.get(item.id);
const sourceLabel = remoteStatus?.contentSource === "github"
? (lang === "zh" ? "GitHub 最新" : "GitHub latest")
: remoteStatus?.contentSource === "cache"
? (lang === "zh" ? "本地缓存" : "Local cache")
: (lang === "zh" ? "打包内置" : "Bundled");
return (
<div className={cx("instruction-row", isCurrent && "selected")} key={item.id}>
<div className="instruction-icon"><Sparkles size={22} /></div>
@@ -1643,12 +2252,16 @@ function App() {
<strong>{item.title}</strong>
<p>{item.subtitle}</p>
<code>./{item.filename}</code>
<div className="prompt-remote-meta" title={remoteStatus?.message || undefined}>
<span className={cx("mini-tag", remoteStatus?.contentSource === "github" ? "ok" : undefined)}>{sourceLabel}</span>
{remoteStatus?.checkedAt && <small>{new Date(remoteStatus.checkedAt).toLocaleString()}</small>}
</div>
</div>
<div className="instruction-status-col">
{isCurrent ? <StatusPill active label={t.provider.current} /> : <span />}
</div>
<div className="instruction-action-col">
<button className="secondary-btn small" onClick={() => switchInstructionTemplate(item.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
<button className="secondary-btn small lively-btn" onClick={() => switchInstructionTemplate(item.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
<button className="ghost-btn small" onClick={disableInstruction} disabled={loading || !isCurrent}>{lang === "zh" ? "禁用" : "Disable"}</button>
</div>
</div>
@@ -1669,7 +2282,7 @@ function App() {
{isCurrent ? <StatusPill active label={t.provider.current} /> : <span />}
</div>
<div className="instruction-action-col">
<button className="secondary-btn small" onClick={() => enableSavedPrompt(prompt.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
<button className="secondary-btn small lively-btn" onClick={() => enableSavedPrompt(prompt.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
<button className="ghost-btn small" onClick={disableInstruction} disabled={loading || !isCurrent}>{lang === "zh" ? "禁用" : "Disable"}</button>
<button className="ghost-btn small" onClick={() => openEditPrompt(prompt)}>{t.provider.edit}</button>
<button className="danger-btn small" onClick={() => removeSavedPrompt(prompt.id)}><Trash2 size={14} /> {t.provider.remove}</button>
@@ -1683,7 +2296,7 @@ function App() {
<div className="instruction-icon custom"><FileCode2 size={22} /></div>
<div className="instruction-main">
<strong>{lang === "zh" ? "当前自定义指令提示词" : "Current custom prompt"}</strong>
<p>{lang === "zh" ? "当前 model_instructions_file 不是 Codex-X 内置模板。" : "The current model_instructions_file is not a built-in Codex-X template."}</p>
<p>{lang === "zh" ? "当前使用的是外部提示词;切换到内置模板前会自动保存到下方列表,之后仍可重新启用。" : "An external prompt is currently active. It will be saved before switching to a built-in template so you can enable it again later."}</p>
<code>{state.instructionFile}</code>
</div>
<div className="instruction-status-col"><StatusPill active label={t.provider.current} /></div>
@@ -1710,8 +2323,8 @@ function App() {
</label>
</div>
<div className="form-actions">
<button className="secondary-btn big" onClick={savePromptOnly} disabled={loading}>{lang === "zh" ? "保存" : "Save"}</button>
<button className="primary-btn big" onClick={saveAndEnablePrompt} disabled={loading}><Zap size={18} /> {lang === "zh" ? "保存并启用" : "Save & enable"}</button>
<button className="secondary-btn big lively-btn" onClick={savePromptOnly} disabled={loading}>{lang === "zh" ? "保存" : "Save"}</button>
<button className="primary-btn big lively-btn" onClick={saveAndEnablePrompt} disabled={loading}><Zap size={18} /> {lang === "zh" ? "保存并启用" : "Save & enable"}</button>
</div>
</div>
)}
@@ -1788,6 +2401,16 @@ function App() {
<div className="settings-copy"><strong>{t.settings.productName}</strong><p>{t.settings.productDesc}</p></div>
<StatusPill active label="Codex-X" />
</div>
<div className="settings-row">
<div className="settings-icon"><CheckCircle2 size={20} /></div>
<div className="settings-copy">
<strong>{lang === "zh" ? "首次启动向导" : "First-run wizard"}</strong>
<p>{lang === "zh" ? "重新检测 CODEX_HOME、config.toml、auth.json 和 SQLite 会话库。" : "Recheck CODEX_HOME, config.toml, auth.json and SQLite session stores."}</p>
</div>
<button className="secondary-btn" onClick={() => { localStorage.removeItem(STARTUP_WIZARD_SEEN_KEY); setStartupWizardOpen(true); refresh(); }}>
{lang === "zh" ? "重新检测" : "Recheck"}
</button>
</div>
</div>
</section>
)}
+512
View File
@@ -1724,3 +1724,515 @@ html, body, #root {
background: linear-gradient(90deg, rgba(16,18,43,.98), rgba(4,19,31,.98));
-webkit-app-region: drag;
}
/* First-run diagnostics wizard */
.startup-mask {
position: fixed;
inset: 0;
z-index: 55;
display: grid;
place-items: center;
padding: 28px;
background: rgba(3, 7, 16, .64);
backdrop-filter: blur(14px);
}
.startup-card {
width: min(880px, calc(100vw - 42px));
border-radius: 30px;
padding: 24px;
display: grid;
gap: 18px;
}
.startup-head,
.startup-actions,
.startup-path-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.startup-head h3 { margin: 2px 0 8px; font-size: 28px; letter-spacing: -.04em; }
.startup-head p { margin: 0; color: #9cabc6; line-height: 1.6; }
.startup-path-row { align-items: end; }
.startup-path-row .field { flex: 1; }
.startup-check-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.startup-check-item {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 12px;
padding: 14px;
border-radius: 18px;
background: rgba(4, 10, 24, .46);
border: 1px solid rgba(255,255,255,.09);
}
.startup-check-item.ok {
border-color: rgba(31,242,178,.24);
background: linear-gradient(135deg, rgba(31,242,178,.10), rgba(4,10,24,.46));
}
.startup-check-item.manual {
border-color: rgba(255,176,32,.25);
background: linear-gradient(135deg, rgba(255,176,32,.10), rgba(4,10,24,.46));
}
.startup-check-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 14px;
color: #2ff5b5;
background: rgba(31,242,178,.10);
}
.startup-check-item:not(.ok) .startup-check-icon { color: #ffcf88; background: rgba(255,176,32,.10); }
.startup-check-item strong { display: block; color: #f4f8ff; margin-bottom: 3px; }
.startup-check-item p { margin: 0 0 5px; color: #9aa9c4; font-size: 13px; }
.startup-check-item code { display: block; color: #bdd1f0; font-size: 12px; }
.startup-actions { justify-content: flex-end; }
/* Enhanced session manager */
.session-list-head-rich { align-items: center; }
.session-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 14px;
flex-wrap: wrap;
}
.session-search {
flex: 1 1 320px;
min-width: 260px;
height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
color: #91a8ca;
background: rgba(4,10,24,.52);
border: 1px solid rgba(255,255,255,.10);
border-radius: 14px;
}
.session-search input {
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
}
.session-toggle {
height: 42px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-radius: 14px;
color: #aebbd3;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.08);
font-size: 13px;
font-weight: 800;
}
.session-toggle input,
.session-select-box input { accent-color: #12d3ff; }
.session-toolbar .small { height: 42px; padding: 0 12px; }
.enhanced-session-list { max-height: 460px; }
.session-group { display: grid; gap: 9px; }
.session-group + .session-group { margin-top: 8px; }
.session-group-title {
position: sticky;
top: 0;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 12px;
color: #9fb0cc;
background: rgba(7, 14, 31, .92);
border: 1px solid rgba(255,255,255,.07);
backdrop-filter: blur(8px);
font-size: 12px;
font-weight: 850;
}
.session-group-title span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.session-group-title em {
flex: 0 0 auto;
font-style: normal;
color: #c7f0ff;
}
.session-row { grid-template-columns: 28px minmax(0, 1fr) auto; }
.session-row.selected {
border-color: rgba(18,211,255,.42);
box-shadow: 0 0 0 2px rgba(18,211,255,.08) inset;
}
.session-select-box {
display: grid;
place-items: center;
}
.session-select-box input { width: 17px; height: 17px; }
.mini-tag.ok {
color: #b8ffe9;
background: rgba(31,242,178,.10);
border-color: rgba(31,242,178,.22);
}
@media (max-width: 1320px) {
.startup-check-grid { grid-template-columns: 1fr; }
.startup-head,
.startup-actions,
.startup-path-row { flex-direction: column; align-items: stretch; }
.session-row { grid-template-columns: 28px 1fr; }
.session-row-meta { grid-column: 2; padding-left: 0; }
}
/* Small interaction polish: make buttons feel alive without changing layout. */
.primary-btn, .secondary-btn, .ghost-btn, .danger-btn {
position: relative;
overflow: hidden;
will-change: transform, filter;
}
.primary-btn:not(:disabled):active,
.secondary-btn:not(:disabled):active,
.ghost-btn:not(:disabled):active,
.danger-btn:not(:disabled):active {
transform: translateY(1px) scale(.985);
filter: brightness(1.12);
}
.primary-btn:not(:disabled)::after,
.secondary-btn:not(:disabled)::after,
.ghost-btn:not(:disabled)::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%), rgba(255,255,255,.22), transparent 32%);
transition: opacity .18s ease;
}
.primary-btn:not(:disabled):hover::after,
.secondary-btn:not(:disabled):hover::after,
.ghost-btn:not(:disabled):hover::after {
opacity: .55;
}
.lively-btn:not(:disabled) {
animation: subtle-ready-pulse 2.8s ease-in-out infinite;
}
.lively-btn:disabled {
animation: none;
}
@keyframes subtle-ready-pulse {
0%, 100% { box-shadow: 0 12px 28px rgba(67,112,255,.18); }
50% { box-shadow: 0 16px 34px rgba(18,211,255,.24); }
}
.hidden-file-input {
position: fixed;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.secret-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.secret-input-wrap input {
width: 100%;
height: 44px;
padding: 0 46px 0 13px;
}
.secret-toggle {
position: absolute;
right: 7px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 11px;
color: #b8c7e4;
background: rgba(255,255,255,.07);
transition: .16s ease;
}
.secret-toggle:hover {
color: #fff;
background: rgba(18,211,255,.16);
transform: scale(1.04);
}
.user-session-stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.session-status-card.needs-sync .session-status-icon,
.user-session-stats .stat-icon:not(.stat-icon-ok) {
animation: attention-glow 1.5s ease-in-out infinite;
}
@keyframes attention-glow {
0%, 100% { box-shadow: 0 0 0 rgba(255,176,32,0); }
50% { box-shadow: 0 0 24px rgba(255,176,32,.22); }
}
.provider-card,
.instruction-row,
.session-row,
.stat-card {
transition: transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease;
}
.provider-card:hover,
.instruction-row:hover,
.session-row:hover {
transform: translateY(-1px);
}
@media (max-width: 1320px) {
.user-session-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.prompt-remote-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 7px;
min-width: 0;
}
.prompt-remote-meta small {
color: #8293b1;
font-size: 11px;
overflow: hidden;
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
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.2.17",
"version": "0.2.20",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: scripts/release.sh <version>" >&2
echo "Example: scripts/release.sh 0.2.18" >&2
exit 1
fi
version="${1#v}"
tag="v${version}"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version: $1" >&2
exit 1
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Working tree has uncommitted changes. Please commit or stash first." >&2
exit 1
fi
if git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag already exists: $tag" >&2
exit 1
fi
VERSION="$version" node --input-type=module <<'NODE'
import fs from 'node:fs';
const version = process.env.VERSION;
for (const file of ['package.json', 'apps/desktop/package.json']) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
data.version = version;
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
}
NODE
VERSION="$version" python3 - <<'PY'
import os
from pathlib import Path
version = os.environ['VERSION']
files = [
Path('apps/desktop/src-tauri/Cargo.toml'),
Path('apps/desktop/src-tauri/tauri.conf.json'),
]
for path in files:
text = path.read_text()
if path.suffix == '.toml':
text = text.replace(next(line for line in text.splitlines() if line.startswith('version = ')), f'version = "{version}"', 1)
else:
import json
data = json.loads(text)
data['version'] = version
text = json.dumps(data, indent=2, ensure_ascii=False) + '\n'
path.write_text(text)
changelog = Path('CHANGELOG.md')
text = changelog.read_text() if changelog.exists() else '# Changelog\n\n'
heading = f'## [v{version}] - TODO'
if f'## [v{version}]' not in text:
lines = text.splitlines()
insert_at = 1 if lines and lines[0].startswith('#') else 0
block = [
'',
heading,
'',
'- TODO: add release highlights.',
]
lines[insert_at:insert_at] = block
changelog.write_text('\n'.join(lines).rstrip() + '\n')
PY
pnpm --dir apps/desktop typecheck
cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml
git add package.json apps/desktop/package.json apps/desktop/src-tauri/Cargo.toml apps/desktop/src-tauri/tauri.conf.json CHANGELOG.md
git commit -m "Release ${tag}"
git tag "$tag"
echo "Prepared ${tag}. Review CHANGELOG TODO, then push with:"
echo " git push origin main ${tag}"