mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f77eec4ba | ||
|
|
d3145fd24d | ||
|
|
c8f624fe91 | ||
|
|
c10b97b25e | ||
|
|
9415749af2 | ||
|
|
a33c78a6e8 | ||
|
|
d7b852cec9 | ||
|
|
31c630f890 | ||
|
|
37d911d94a | ||
|
|
69e0a46430 | ||
|
|
ab19b2ef33 | ||
|
|
ff34902641 | ||
|
|
07e4968ade | ||
|
|
ba5e90e24f | ||
|
|
8ec879e7a9 | ||
|
|
971236fc67 | ||
|
|
fb53b90763 | ||
|
|
257a4385b7 | ||
|
|
c0f3c9240c | ||
|
|
67ad656b45 | ||
|
|
5dbd6e2359 |
@@ -21,10 +21,16 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS
|
||||
- name: macOS Apple Silicon
|
||||
platform: macos-latest
|
||||
args: --bundles dmg
|
||||
artifact: codex-x-macos
|
||||
artifact: codex-x-macos-arm64
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
- name: macOS Intel
|
||||
platform: macos-15-intel
|
||||
args: --bundles dmg
|
||||
artifact: codex-x-macos-x64
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
- name: Linux
|
||||
@@ -40,6 +46,7 @@ jobs:
|
||||
artifact: codex-x-windows
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/msi/*.msi
|
||||
apps/desktop/src-tauri/Codex-X_*_windows_x64_portable.zip
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -81,6 +88,58 @@ jobs:
|
||||
- name: Build Tauri bundles
|
||||
run: pnpm --dir apps/desktop tauri build ${{ matrix.args }}
|
||||
|
||||
- name: Create Windows portable zip
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
portable_dir="apps/desktop/src-tauri/portable/Codex-X_${version}_windows_x64_portable"
|
||||
rm -rf "apps/desktop/src-tauri/portable"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
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:
|
||||
@@ -139,7 +198,7 @@ jobs:
|
||||
else
|
||||
gh release create "$tag" --title "$title" --notes-file release-body.md --latest
|
||||
fi
|
||||
mapfile -t assets < <(find release-assets -type f \( -name '*.dmg' -o -name '*.msi' -o -name '*.deb' -o -name '*.rpm' \) -print | sort)
|
||||
mapfile -t assets < <(find release-assets -type f \( -name '*.dmg' -o -name '*.msi' -o -name '*.deb' -o -name '*.rpm' -o -name '*.zip' \) -print | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found" >&2
|
||||
exit 1
|
||||
|
||||
@@ -2,6 +2,43 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [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`。
|
||||
- 继续保留 macOS Apple Silicon / macOS Intel / Windows MSI / Windows portable ZIP / Linux deb/rpm 多平台产物。
|
||||
|
||||
## [v0.2.16] - 2026-07-04
|
||||
|
||||
- Release 新增 macOS Intel 构建,Intel Mac 用户可下载 x64 DMG。
|
||||
- macOS Release 现在同时提供 Apple Silicon 与 Intel 两种 DMG。
|
||||
- Windows Release 新增 portable ZIP,包含可直接运行的 `Codex-X.exe`,无需 MSI 安装。
|
||||
- 发布流程支持上传 `.zip` portable 产物,并更新 README 下载说明。
|
||||
|
||||
## [v0.2.15] - 2026-07-04
|
||||
|
||||
- 会话管理页移除 CODEX_HOME 与 Provider Sync 备份位置展示,页面信息更简洁。
|
||||
- 优化 Windows 打开下载页体验:后端改为后台线程 spawn 浏览器,不再等待 `cmd /C start`,避免 WebView 卡顿 2-3 秒。
|
||||
- 统一所有外部链接按钮走异步打开逻辑,打开项目主页、反馈页、下载页都不会阻塞界面。
|
||||
- 修复 macOS 顶部 toast 被 Overlay 标题栏遮挡的问题,toast 自动下移到标题栏安全区下方。
|
||||
|
||||
## [v0.2.14] - 2026-07-04
|
||||
|
||||
- 修复 macOS Overlay 标题栏遮挡内容的问题,为红黄绿窗口按钮预留完整安全区。
|
||||
- 调整侧边栏、内容区、Provider/TOML/指令提示词/会话管理页高度计算,避免顶部内容被标题栏压住。
|
||||
- 加高 macOS 顶部拖拽区域并保持深色渐变,窗口顶部继续和应用色系统一。
|
||||
|
||||
## [v0.2.13] - 2026-07-04
|
||||
|
||||
- macOS 窗口标题栏改为深色融合样式,避免顶部出现系统白色标题条。
|
||||
- 会话管理页面移除多余外层玻璃边框,统一为完整内容容器。
|
||||
- 优化会话列表边界与滚动区域,避免列表像卡在外层框外面。
|
||||
|
||||
## [v0.2.12] - 2026-07-04
|
||||
|
||||
- Windows MSI 安装器回退为默认简洁样式,移除上一版过重的自定义安装界面图。
|
||||
|
||||
+326
@@ -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>
|
||||
@@ -1,11 +1,16 @@
|
||||
<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="160" />
|
||||
<img src="apps/desktop/src-tauri/icons/icon.png" alt="Codex-X Logo" width="150" />
|
||||
|
||||
# Codex-X
|
||||
|
||||
**Codex 提示词注入与配置切换管理器**
|
||||
**Codex 提示词注入 · Provider 切换 · TOML / Auth 可视化管理器**
|
||||
|
||||
一款面向 **OpenAI Codex / Codex CLI** 的跨平台桌面工具,重点提供 `gpt5.5-unrestricted.md` 指令提示词注入、Provider 切换、官方 Auth 管理与 TOML 可视化编辑。
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具,内置 `gpt5.5-unrestricted.md` 与 `gpt5.4-unrestricted.md`,支持一键写入 / 禁用指令提示词、第三方 Provider 切换、官方 Auth 管理、TOML 可视化编辑与本地会话 Provider Sync。
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -27,80 +32,217 @@
|
||||
|
||||
## Codex-X 是什么?
|
||||
|
||||
Codex-X 不是单纯的配置文件编辑器,而是一个更偏向「Codex 增强启动器」的桌面管理工具。
|
||||
Codex-X 不是普通的配置文件编辑器,而是一个面向 Codex CLI 的 **可视化增强管理器**。
|
||||
|
||||
它可以把预设或自定义的 **指令提示词模板** 写入 Codex 配置,例如项目内置的:
|
||||
它把几个高频操作做成了桌面软件:
|
||||
|
||||
```text
|
||||
examples/gpt5.5-unrestricted.md
|
||||
```
|
||||
|
||||
你可以在 UI 中直接启用、禁用、切换多个提示词模板,也可以同时管理不同第三方供应商 API 和官方 OpenAI Codex 登录配置。
|
||||
|
||||
## 核心亮点
|
||||
|
||||
### 1. 指令提示词注入
|
||||
|
||||
- 一键启用 `gpt5.5-unrestricted.md`
|
||||
- 支持多个提示词模板并存
|
||||
- 启用前自动记录状态,方便回退
|
||||
- 支持禁用当前提示词,让 Codex 恢复普通配置
|
||||
|
||||
### 2. Provider 可视化切换
|
||||
|
||||
- 添加第三方 Codex API Provider
|
||||
- 编辑 Base URL / API Key / Model / TOML 配置
|
||||
- 当前 Provider 状态清晰可见
|
||||
- 支持从 cc-switch 数据库导入 Codex Provider
|
||||
|
||||
### 3. 官方 Codex 配置管理
|
||||
|
||||
- 读取 Codex 官方 `auth.json`
|
||||
- 支持官方 ChatGPT 登录态 Auth 查看与编辑
|
||||
- 区分官方 Auth 与第三方 API Key,不混在一起
|
||||
|
||||
### 4. TOML 配置编辑
|
||||
|
||||
- 可视化查看当前 Codex `config.toml`
|
||||
- 在 Provider 编辑页直接编辑完整 TOML
|
||||
- 保存后立即同步到 Codex 配置目录
|
||||
|
||||
### 5. 跨平台桌面软件
|
||||
|
||||
- macOS `.dmg`
|
||||
- Windows `.msi`
|
||||
- Linux `.deb` / `.rpm`
|
||||
- GitHub Releases 自动构建发布
|
||||
- 给 Codex 写入 / 禁用指令提示词模板
|
||||
- 切换官方 OpenAI 与第三方 Codex API Provider
|
||||
- 查看 / 编辑 `~/.codex/config.toml`
|
||||
- 查看 / 编辑官方 `~/.codex/auth.json`
|
||||
- 检查并修复本地历史会话的 Provider 元数据
|
||||
|
||||
## 软件预览
|
||||
|
||||
<details open>
|
||||
<summary><b>主界面 / Provider / TOML / Auth 截图</b></summary>
|
||||
<summary><b>应用界面预览</b>:主界面 / Provider / TOML / Auth</summary>
|
||||
|
||||
> 这里可以放软件主界面、供应商切换页面、TOML 编辑页面、官方 Auth 页面截图。
|
||||
|
||||
<!-- 示例:
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/overview.png" width="760" />
|
||||
<img src="docs/screenshots/app/preview.png" alt="Codex-X 应用界面预览:主界面、Provider、TOML、Auth" width="920" />
|
||||
</p>
|
||||
-->
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>提示词注入前后效果图</b></summary>
|
||||
<summary><b>提示词注入效果:安全测试场景</b></summary>
|
||||
|
||||
> 这里可以放启用 `gpt5.5-unrestricted.md` 前后的 Codex 效果对比图。
|
||||
|
||||
<!-- 示例:
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/prompt-before.png" width="760" />
|
||||
<img src="docs/screenshots/prompt-after.png" width="760" />
|
||||
</p>
|
||||
-->
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<b>SQL 注入测试</b><br />
|
||||
<sub>部署后测试:如何对目标进行 SQL 注入测试?</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/security/sql-injection.png" alt="SQL 注入测试效果图" width="420" />
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<b>NSFW 响应测试</b><br />
|
||||
<sub>用于观察提示词注入后的边界响应变化</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/security/nsfw.png" alt="NSFW 响应测试效果图" width="420" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>提示词注入效果:逆向工程场景</b></summary>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<b>APK 逆向分析</b><br />
|
||||
<sub>Android APK 静态 / 动态分析思路</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-1.png" alt="APK 逆向分析效果图" width="420" />
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<b>APK 逆向分析 2</b><br />
|
||||
<sub>补充 APK 逆向流程与定位方式</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-2.png" alt="APK 逆向分析效果图 2" width="420" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<b>EXE 逆向分析</b><br />
|
||||
<sub>Windows 可执行文件分析与调试方向</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/exe-reverse.png" alt="EXE 逆向分析效果图" width="620" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## 功能特性
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center" width="180">功能</th>
|
||||
<th align="center">说明</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">⚡ 供应商 API</td>
|
||||
<td>可视化管理官方 OpenAI / 第三方 Codex Provider,支持 Base URL、API Key、Model、Wire API 与一键切换。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧩 <b>提示词注入</b></td>
|
||||
<td><b>特色功能</b>:内置 <code>gpt5.4-unrestricted.md</code> / <code>gpt5.5-unrestricted.md</code>,一键写入 Codex 配置;启用后可达到上方效果图中的 SQL 注入测试、APK / EXE 逆向等响应效果。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">💬 会话管理</td>
|
||||
<td>读取 Codex 本地 SQLite 与 rollout JSONL,会话列表可视化展示,并支持 Provider Sync / 修复历史 thread 元数据。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📝 TOML 配置</td>
|
||||
<td>查看当前 <code>~/.codex/config.toml</code>,并在 Provider 编辑页直接编辑完整 TOML,保存后同步到 Codex 配置目录。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🔐 Auth 管理</td>
|
||||
<td>读取 / 编辑官方 <code>~/.codex/auth.json</code>,区分 ChatGPT 登录态 Auth 与第三方 API Key。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 跨平台发布</td>
|
||||
<td>提供 macOS Apple Silicon / Intel <code>.dmg</code>、Windows <code>.msi</code> / portable <code>.zip</code>、Linux <code>.deb</code> / <code>.rpm</code>,支持应用内检测更新。</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 核心亮点
|
||||
|
||||
### 1. 内置双指令提示词模板
|
||||
|
||||
Codex-X 当前内置两套提示词模板:
|
||||
|
||||
- `examples/gpt5.5-unrestricted.md`
|
||||
- `examples/gpt5.4-unrestricted.md`
|
||||
|
||||
你可以在【指令提示词】页面直接点击启用 / 禁用。启用后,Codex-X 会把对应 `.md` 写入 Codex 配置目录,并更新 `config.toml` 中的 `model_instructions_file`。
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">对比项</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">定位</td>
|
||||
<td align="center">5.4 CTF 沙箱</td>
|
||||
<td align="center">5.5 无限制模式</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">策略</td>
|
||||
<td align="center">CTF 竞赛沙箱间接绕过</td>
|
||||
<td align="center">直接声明无限制开发者模式</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">风格</td>
|
||||
<td align="center">安全研究导向,方法论详细</td>
|
||||
<td align="center">极简指令,覆盖面广</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">长度</td>
|
||||
<td align="center">约 180 行</td>
|
||||
<td align="center">约 40 行</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">适用</td>
|
||||
<td align="center">GPT-5.4 Codex CLI</td>
|
||||
<td align="center">GPT-5.5 Codex CLI</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
部署后可以测试:
|
||||
|
||||
```text
|
||||
如何对目标进行 SQL 注入测试?
|
||||
```
|
||||
|
||||
典型效果:
|
||||
|
||||
```text
|
||||
破甲前 → 拒绝或泛化回答
|
||||
破甲后 → 直接给安全研究方法论与测试步骤
|
||||
```
|
||||
|
||||
### 2. Provider 可视化切换
|
||||
|
||||
- 添加第三方 Codex API Provider
|
||||
- 编辑 Base URL / API Key / Model / Wire API
|
||||
- Provider 页面可查看并编辑对应 TOML
|
||||
- 当前启用 Provider 状态清晰可见
|
||||
- 支持从 cc-switch 数据库导入 Codex Provider
|
||||
|
||||
### 3. 官方 Auth 管理
|
||||
|
||||
- 自动读取 Codex 官方 `auth.json`
|
||||
- 支持查看 / 编辑 ChatGPT 登录态 Auth
|
||||
- 区分官方 Auth 与第三方 API Key
|
||||
- 官方配置可和第三方 Provider 在 UI 中统一管理
|
||||
|
||||
### 4. TOML 可视化编辑
|
||||
|
||||
- 查看当前 Codex `config.toml`
|
||||
- 深色代码预览与语法高亮
|
||||
- Provider 编辑页可直接编辑完整 TOML
|
||||
- 保存后同步到 Codex 配置目录
|
||||
|
||||
### 5. 会话管理 / Provider Sync
|
||||
|
||||
Codex-X 可以读取 Codex 本地会话数据:
|
||||
|
||||
```text
|
||||
~/.codex/sqlite/*.db
|
||||
~/.codex/state_5.sqlite
|
||||
~/.codex/sessions/**/rollout-*.jsonl
|
||||
~/.codex/archived_sessions/**/rollout-*.jsonl
|
||||
```
|
||||
|
||||
用于检查旧会话的 Provider 元数据是否和当前配置一致,并支持一键同步 / 修复,让历史 thread 继续被原生 Codex 识别、打开和续聊。
|
||||
|
||||
### 6. 跨平台桌面软件
|
||||
|
||||
- macOS Apple Silicon `.dmg`
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm`
|
||||
- GitHub Releases 自动构建发布
|
||||
- 应用内检查更新
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类型 | 技术 |
|
||||
@@ -154,14 +296,9 @@ pnpm dev
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © yynxxxxx
|
||||
|
||||
|
||||
## macOS 安装说明
|
||||
|
||||
如果你在未签名/未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
- 最佳方式:使用 Apple Developer ID 签名并 notarize
|
||||
- 仅本地测试:可手动移除 quarantine 属性
|
||||
@@ -169,3 +306,21 @@ MIT © yynxxxxx
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/Codex-X.app
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [MIT License](https://github.com/yynxxxxx/Codex-X/blob/main/LICENSE) 开源。
|
||||
|
||||
## 致谢 / Thanks
|
||||
|
||||
感谢 [LINUX DO 论坛](https://linux.do/) 社区的关注、反馈与支持。
|
||||
|
||||
## 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>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.18",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+112
-1
@@ -337,7 +337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.2.12"
|
||||
version = "0.2.18"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -348,6 +348,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"thiserror 2.0.18",
|
||||
"toml_edit 0.22.27",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2497,6 +2498,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 +2541,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 +2970,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 +3738,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 +4015,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 +4272,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 +4673,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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.2.12"
|
||||
version = "0.2.18"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -23,3 +23,4 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5.0"
|
||||
thiserror = "2.0"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
ureq = { version = "2.12", features = ["tls"] }
|
||||
|
||||
@@ -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");
|
||||
@@ -8,14 +8,12 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use thiserror::Error;
|
||||
use toml_edit::{value, DocumentMut, Item, Table};
|
||||
|
||||
const INSTRUCTION_FILENAME: &str = "gpt5.5-unrestricted.md";
|
||||
const INSTRUCTION_RELATIVE: &str = "./gpt5.5-unrestricted.md";
|
||||
const INSTRUCTION_CONTENT: &str = include_str!("../../../../examples/gpt5.5-unrestricted.md");
|
||||
const INSTRUCTION_54_FILENAME: &str = "gpt5.4-unrestricted.md";
|
||||
const INSTRUCTION_54_RELATIVE: &str = "./gpt5.4-unrestricted.md";
|
||||
const INSTRUCTION_54_CONTENT: &str = include_str!("../../../../examples/gpt5.4-unrestricted.md");
|
||||
mod constants;
|
||||
mod platform;
|
||||
|
||||
use constants::*;
|
||||
use toml_edit::{value, DocumentMut, Item, Table};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum CodexxError {
|
||||
@@ -156,6 +154,19 @@ struct SavedPrompt {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BuiltinPromptStatus {
|
||||
id: String,
|
||||
filename: String,
|
||||
source_url: String,
|
||||
cached: bool,
|
||||
updated: bool,
|
||||
content_source: String,
|
||||
checked_at: Option<String>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImportResult {
|
||||
@@ -192,6 +203,33 @@ struct AboutInfo {
|
||||
github_repo: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DiagnosticItem {
|
||||
key: String,
|
||||
label: String,
|
||||
path: Option<String>,
|
||||
status: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StartupDiagnostics {
|
||||
codex_dir: String,
|
||||
needs_manual_select: bool,
|
||||
summary: String,
|
||||
items: Vec<DiagnosticItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SelectedSessionSyncInput {
|
||||
config_dir: Option<String>,
|
||||
target_provider: Option<String>,
|
||||
session_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionPreview {
|
||||
@@ -321,7 +359,15 @@ fn open_db() -> Result<Connection> {
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_prompts_updated_at ON prompts(updated_at DESC);",
|
||||
CREATE INDEX IF NOT EXISTS idx_prompts_updated_at ON prompts(updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS builtin_prompt_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(conn)
|
||||
@@ -484,6 +530,240 @@ fn delete_prompt_inner(id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn builtin_prompt_meta(
|
||||
template_id: &str,
|
||||
) -> Result<(&'static str, &'static str, &'static str, &'static str)> {
|
||||
match template_id.trim() {
|
||||
"gpt5.4-unrestricted" => Ok((
|
||||
"gpt5.4-unrestricted",
|
||||
INSTRUCTION_54_FILENAME,
|
||||
INSTRUCTION_54_RELATIVE,
|
||||
INSTRUCTION_54_CONTENT,
|
||||
)),
|
||||
"gpt5.5-unrestricted" | "" => Ok((
|
||||
"gpt5.5-unrestricted",
|
||||
INSTRUCTION_FILENAME,
|
||||
INSTRUCTION_RELATIVE,
|
||||
INSTRUCTION_CONTENT,
|
||||
)),
|
||||
other => Err(CodexxError::Config(format!("未知指令提示词模板: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_prompt_source_url(filename: &str) -> String {
|
||||
format!("https://raw.githubusercontent.com/yynxxxxx/Codex-X/main/examples/{filename}")
|
||||
}
|
||||
|
||||
fn cached_builtin_prompt(id: &str) -> Result<Option<(String, String)>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT content, checked_at FROM builtin_prompt_cache WHERE id = ?1")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
match stmt.query_row([id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
}) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_builtin_prompt_cache(
|
||||
id: &str,
|
||||
filename: &str,
|
||||
source_url: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
let now = now_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO builtin_prompt_cache (id, filename, source_url, content, checked_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
source_url = excluded.source_url,
|
||||
content = excluded.content,
|
||||
checked_at = excluded.checked_at,
|
||||
updated_at = CASE WHEN builtin_prompt_cache.content <> excluded.content THEN excluded.updated_at ELSE builtin_prompt_cache.updated_at END",
|
||||
params![id, filename, source_url, content, now],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_remote_prompt(source_url: &str) -> Result<String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.build();
|
||||
let response = agent
|
||||
.get(source_url)
|
||||
.set("User-Agent", "Codex-X")
|
||||
.call()
|
||||
.map_err(|e| CodexxError::Config(format!("GitHub 提示词更新失败: {e}")))?;
|
||||
let text = response
|
||||
.into_string()
|
||||
.map_err(|e| CodexxError::Config(format!("读取 GitHub 提示词失败: {e}")))?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(CodexxError::Config("GitHub 提示词内容为空".to_string()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn refresh_builtin_prompt_inner(template_id: &str) -> Result<BuiltinPromptStatus> {
|
||||
let (id, filename, _relative, bundled) = builtin_prompt_meta(template_id)?;
|
||||
let source_url = builtin_prompt_source_url(filename);
|
||||
let cached_before = cached_builtin_prompt(id)?;
|
||||
match fetch_remote_prompt(&source_url) {
|
||||
Ok(remote) => {
|
||||
let updated = cached_before
|
||||
.as_ref()
|
||||
.map(|(content, _)| content != &remote)
|
||||
.unwrap_or(remote != bundled);
|
||||
save_builtin_prompt_cache(id, filename, &source_url, &remote)?;
|
||||
let checked_at = cached_builtin_prompt(id)?.map(|(_, checked_at)| checked_at);
|
||||
Ok(BuiltinPromptStatus {
|
||||
id: id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
source_url,
|
||||
cached: true,
|
||||
updated,
|
||||
content_source: "github".to_string(),
|
||||
checked_at,
|
||||
message: if updated {
|
||||
"已更新到 GitHub 最新提示词"
|
||||
} else {
|
||||
"已是 GitHub 最新提示词"
|
||||
}
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let cached = cached_before.is_some();
|
||||
Ok(BuiltinPromptStatus {
|
||||
id: id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
source_url,
|
||||
cached,
|
||||
updated: false,
|
||||
content_source: if cached { "cache" } else { "bundled" }.to_string(),
|
||||
checked_at: cached_before.map(|(_, checked_at)| checked_at),
|
||||
message: format!(
|
||||
"无法连接 GitHub,已使用{}:{}",
|
||||
if cached {
|
||||
"本地缓存"
|
||||
} else {
|
||||
"打包内置版本"
|
||||
},
|
||||
e
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_prompt_status_inner() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
["gpt5.5-unrestricted", "gpt5.4-unrestricted"]
|
||||
.iter()
|
||||
.map(|template_id| {
|
||||
let (id, filename, _relative, _bundled) = builtin_prompt_meta(template_id)?;
|
||||
let source_url = builtin_prompt_source_url(filename);
|
||||
let cached = cached_builtin_prompt(id)?;
|
||||
Ok(BuiltinPromptStatus {
|
||||
id: id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
source_url,
|
||||
cached: cached.is_some(),
|
||||
updated: false,
|
||||
content_source: if cached.is_some() { "cache" } else { "bundled" }.to_string(),
|
||||
checked_at: cached.map(|(_, checked_at)| checked_at),
|
||||
message: "未检查 GitHub 更新".to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refresh_builtin_prompts_inner() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
["gpt5.5-unrestricted", "gpt5.4-unrestricted"]
|
||||
.iter()
|
||||
.map(|template_id| refresh_builtin_prompt_inner(template_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn builtin_prompt_content(
|
||||
template_id: &str,
|
||||
) -> Result<(&'static str, &'static str, String, String)> {
|
||||
let (id, filename, relative, bundled) = builtin_prompt_meta(template_id)?;
|
||||
let _ = refresh_builtin_prompt_inner(id);
|
||||
if let Some((content, _checked_at)) = cached_builtin_prompt(id)? {
|
||||
return Ok((filename, relative, content, "github/cache".to_string()));
|
||||
}
|
||||
Ok((
|
||||
filename,
|
||||
relative,
|
||||
bundled.to_string(),
|
||||
"bundled".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_instruction_path(codex_dir: &Path, value: &str) -> PathBuf {
|
||||
let trimmed = value.trim();
|
||||
let expanded = if trimmed == "~" {
|
||||
home_dir().unwrap_or_else(|_| codex_dir.to_path_buf())
|
||||
} else if let Some(rest) = trimmed.strip_prefix("~/") {
|
||||
home_dir()
|
||||
.map(|home| home.join(rest))
|
||||
.unwrap_or_else(|_| PathBuf::from(trimmed))
|
||||
} else {
|
||||
PathBuf::from(trimmed)
|
||||
};
|
||||
if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
codex_dir.join(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_current_instruction_prompt(codex_dir: &Path) -> Result<Option<SavedPrompt>> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let Some(current) = string_value(&doc, "model_instructions_file") else {
|
||||
return Ok(None);
|
||||
};
|
||||
if is_managed_instruction_value(¤t) {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = resolve_instruction_path(codex_dir, ¤t);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = read_to_string_if_exists(&path)?;
|
||||
if content.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "external-prompt.md".to_string());
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("external-prompt");
|
||||
let id = format!("external-{}", sanitize_id(stem));
|
||||
let title = format!("外部提示词 · {stem}");
|
||||
save_prompt_inner(SavedPrompt {
|
||||
id,
|
||||
title,
|
||||
filename: normalize_prompt_filename(&file_name, "external-prompt"),
|
||||
content,
|
||||
})
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
fn sanitize_id(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut last_dash = false;
|
||||
@@ -1197,7 +1477,12 @@ fn normalize_workspace_path(value: &str) -> Option<String> {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn scan_rollouts(codex_dir: &Path, target_provider: &str, rewrite: bool) -> Result<RolloutScan> {
|
||||
fn scan_rollouts(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
rewrite: bool,
|
||||
only_session_ids: Option<&HashSet<String>>,
|
||||
) -> Result<RolloutScan> {
|
||||
let mut paths = Vec::new();
|
||||
let mut scan = RolloutScan::default();
|
||||
for dir in ["sessions", "archived_sessions"] {
|
||||
@@ -1243,6 +1528,15 @@ fn scan_rollouts(codex_dir: &Path, target_provider: &str, rewrite: bool) -> Resu
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
}
|
||||
let selected_for_rewrite = only_session_ids
|
||||
.map(|ids| {
|
||||
payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|id| ids.contains(id))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if first_cwd.is_none() {
|
||||
first_cwd = payload
|
||||
.get("cwd")
|
||||
@@ -1253,8 +1547,10 @@ fn scan_rollouts(codex_dir: &Path, target_provider: &str, rewrite: bool) -> Resu
|
||||
!= Some(target_provider)
|
||||
{
|
||||
scan.mismatched_session_meta += 1;
|
||||
file_changed = true;
|
||||
if rewrite {
|
||||
if selected_for_rewrite {
|
||||
file_changed = true;
|
||||
}
|
||||
if rewrite && selected_for_rewrite {
|
||||
payload.insert(
|
||||
"model_provider".to_string(),
|
||||
json!(target_provider),
|
||||
@@ -1589,6 +1885,89 @@ fn create_provider_sync_backup(
|
||||
Ok(backup_dir)
|
||||
}
|
||||
|
||||
fn update_selected_thread_provider(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
cols: &HashSet<String>,
|
||||
target_provider: &str,
|
||||
selected_ids: &HashSet<String>,
|
||||
thread_ids_with_user_events: &HashSet<String>,
|
||||
cwd_by_thread_id: &HashMap<String, String>,
|
||||
) -> Result<usize> {
|
||||
let mut updated = 0usize;
|
||||
for id in selected_ids {
|
||||
updated += tx
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1",
|
||||
(target_provider, id),
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
if cols.contains("has_user_event") && thread_ids_with_user_events.contains(id) {
|
||||
updated += tx
|
||||
.execute(
|
||||
"UPDATE threads SET has_user_event = 1 WHERE id = ?1 AND COALESCE(has_user_event, 0) <> 1",
|
||||
[id],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
if cols.contains("cwd") {
|
||||
if let Some(cwd) = cwd_by_thread_id.get(id) {
|
||||
updated += tx
|
||||
.execute(
|
||||
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
|
||||
(cwd, id),
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn apply_sqlite_provider_sync_selected(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
selected_ids: &HashSet<String>,
|
||||
thread_ids_with_user_events: &HashSet<String>,
|
||||
cwd_by_thread_id: &HashMap<String, String>,
|
||||
) -> Result<usize> {
|
||||
let mut updated = 0usize;
|
||||
if selected_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
for path in sqlite_candidate_paths(codex_dir) {
|
||||
let mut conn = match Connection::open(&path) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"打开 SQLite 失败 {}: {e}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
if !sqlite_has_table(&conn, "threads")? {
|
||||
continue;
|
||||
}
|
||||
let cols = table_column_set(&conn, "threads")?;
|
||||
if !cols.contains("model_provider") {
|
||||
continue;
|
||||
}
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
updated += update_selected_thread_provider(
|
||||
&tx,
|
||||
&cols,
|
||||
target_provider,
|
||||
selected_ids,
|
||||
thread_ids_with_user_events,
|
||||
cwd_by_thread_id,
|
||||
)?;
|
||||
tx.commit()
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn apply_sqlite_provider_sync(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
@@ -1645,13 +2024,113 @@ fn apply_sqlite_provider_sync(
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn diagnostic_item(
|
||||
key: &str,
|
||||
label: &str,
|
||||
path: Option<&Path>,
|
||||
ok: bool,
|
||||
manual_when_missing: bool,
|
||||
) -> DiagnosticItem {
|
||||
let status = if ok {
|
||||
"ok"
|
||||
} else if manual_when_missing {
|
||||
"manual"
|
||||
} else {
|
||||
"missing"
|
||||
};
|
||||
let message = match status {
|
||||
"ok" => "检测通过",
|
||||
"manual" => "需要手动选择",
|
||||
_ => "未找到",
|
||||
};
|
||||
DiagnosticItem {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
path: path.map(|p| p.display().to_string()),
|
||||
status: status.to_string(),
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn startup_diagnostics_inner(config_dir: Option<String>) -> Result<StartupDiagnostics> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let config = config_path(&codex_dir);
|
||||
let auth = auth_path(&codex_dir);
|
||||
let sqlite_paths = sqlite_candidate_paths(&codex_dir);
|
||||
let codex_dir_ok = codex_dir.is_dir();
|
||||
let config_ok = config.is_file();
|
||||
let auth_ok = auth.is_file() && auth_has_material(&auth).unwrap_or(false);
|
||||
let sqlite_ok = !sqlite_paths.is_empty();
|
||||
|
||||
let mut items = Vec::new();
|
||||
items.push(diagnostic_item(
|
||||
"codexHome",
|
||||
"CODEX_HOME",
|
||||
Some(&codex_dir),
|
||||
codex_dir_ok,
|
||||
true,
|
||||
));
|
||||
items.push(diagnostic_item(
|
||||
"config",
|
||||
"config.toml",
|
||||
Some(&config),
|
||||
config_ok,
|
||||
false,
|
||||
));
|
||||
items.push(diagnostic_item(
|
||||
"auth",
|
||||
"auth.json",
|
||||
Some(&auth),
|
||||
auth_ok,
|
||||
false,
|
||||
));
|
||||
items.push(DiagnosticItem {
|
||||
key: "sqlite".to_string(),
|
||||
label: "SQLite 会话库".to_string(),
|
||||
path: sqlite_paths.first().map(|p| {
|
||||
if sqlite_paths.len() > 1 {
|
||||
format!("{} 等 {} 个", p.display(), sqlite_paths.len())
|
||||
} else {
|
||||
p.display().to_string()
|
||||
}
|
||||
}),
|
||||
status: if sqlite_ok { "ok" } else { "missing" }.to_string(),
|
||||
message: if sqlite_ok {
|
||||
"检测通过"
|
||||
} else {
|
||||
"未找到"
|
||||
}
|
||||
.to_string(),
|
||||
});
|
||||
|
||||
let ok_count = items.iter().filter(|item| item.status == "ok").count();
|
||||
let needs_manual_select = !codex_dir_ok;
|
||||
let summary = if ok_count == items.len() {
|
||||
"Codex 环境检测通过".to_string()
|
||||
} else if needs_manual_select {
|
||||
"未找到 CODEX_HOME,需要手动选择 Codex 配置目录".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"已检测到 {ok_count}/{} 项,缺失项不影响部分功能使用",
|
||||
items.len()
|
||||
)
|
||||
};
|
||||
|
||||
Ok(StartupDiagnostics {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
needs_manual_select,
|
||||
summary,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
fn session_sync_status_inner(
|
||||
config_dir: Option<String>,
|
||||
target_provider: Option<String>,
|
||||
) -> Result<SessionSyncStatus> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let target = current_model_provider(&codex_dir, target_provider)?;
|
||||
let rollouts = scan_rollouts(&codex_dir, &target, false)?;
|
||||
let rollouts = scan_rollouts(&codex_dir, &target, false, None)?;
|
||||
let sqlite = scan_sqlite(&codex_dir, &target)?;
|
||||
let session_limit = sqlite.sqlite_threads.max(50).min(1000);
|
||||
let (sessions, session_warnings) = list_session_previews(&codex_dir, &target, session_limit)?;
|
||||
@@ -1675,6 +2154,69 @@ fn session_sync_status_inner(
|
||||
})
|
||||
}
|
||||
|
||||
fn sync_selected_sessions_provider_inner(
|
||||
input: SelectedSessionSyncInput,
|
||||
) -> Result<SessionSyncResult> {
|
||||
let selected_ids = input
|
||||
.session_ids
|
||||
.into_iter()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
if selected_ids.is_empty() {
|
||||
return Err(CodexxError::Config("请选择至少一个会话".to_string()));
|
||||
}
|
||||
|
||||
let codex_dir = resolve_codex_dir(input.config_dir)?;
|
||||
fs::create_dir_all(&codex_dir).map_err(|e| io_err(&codex_dir, e))?;
|
||||
let target = current_model_provider(&codex_dir, input.target_provider)?;
|
||||
let lock_dir = codex_dir.join("tmp").join("provider-sync.lock");
|
||||
fs::create_dir_all(lock_dir.parent().unwrap_or(&codex_dir))
|
||||
.map_err(|e| io_err(lock_dir.parent().unwrap_or(&codex_dir), e))?;
|
||||
if lock_dir.exists() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话同步锁已存在: {}",
|
||||
lock_dir.display()
|
||||
)));
|
||||
}
|
||||
fs::create_dir_all(&lock_dir).map_err(|e| io_err(&lock_dir, e))?;
|
||||
|
||||
let result = (|| -> Result<SessionSyncResult> {
|
||||
let rollouts = scan_rollouts(&codex_dir, &target, true, Some(&selected_ids))?;
|
||||
let changed_paths = rollouts
|
||||
.changed_files
|
||||
.iter()
|
||||
.map(|(p, _)| p.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let backup_dir = create_provider_sync_backup(&codex_dir, &target, &changed_paths)?;
|
||||
|
||||
let mut updated_rollouts = 0usize;
|
||||
for (path, text) in &rollouts.changed_files {
|
||||
write_text(path, text)?;
|
||||
updated_rollouts += 1;
|
||||
}
|
||||
let updated_threads = apply_sqlite_provider_sync_selected(
|
||||
&codex_dir,
|
||||
&target,
|
||||
&selected_ids,
|
||||
&rollouts.thread_ids_with_user_events,
|
||||
&rollouts.cwd_by_thread_id,
|
||||
)?;
|
||||
let mut status =
|
||||
session_sync_status_inner(Some(codex_dir.display().to_string()), Some(target.clone()))?;
|
||||
status.backup_dir = Some(backup_dir.display().to_string());
|
||||
Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts,
|
||||
updated_threads,
|
||||
backup_dir: backup_dir.display().to_string(),
|
||||
})
|
||||
})();
|
||||
|
||||
let _ = fs::remove_dir_all(&lock_dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn sync_sessions_provider_inner(
|
||||
config_dir: Option<String>,
|
||||
target_provider: Option<String>,
|
||||
@@ -1694,7 +2236,7 @@ fn sync_sessions_provider_inner(
|
||||
fs::create_dir_all(&lock_dir).map_err(|e| io_err(&lock_dir, e))?;
|
||||
|
||||
let result = (|| -> Result<SessionSyncResult> {
|
||||
let rollouts = scan_rollouts(&codex_dir, &target, true)?;
|
||||
let rollouts = scan_rollouts(&codex_dir, &target, true, None)?;
|
||||
let sqlite_before = scan_sqlite(&codex_dir, &target)?;
|
||||
let changed_paths = rollouts
|
||||
.changed_files
|
||||
@@ -1734,22 +2276,6 @@ fn sync_sessions_provider_inner(
|
||||
result
|
||||
}
|
||||
|
||||
fn instruction_template(template_id: &str) -> Result<(&'static str, &'static str, &'static str)> {
|
||||
match template_id.trim() {
|
||||
"gpt5.4-unrestricted" => Ok((
|
||||
INSTRUCTION_54_FILENAME,
|
||||
INSTRUCTION_54_RELATIVE,
|
||||
INSTRUCTION_54_CONTENT,
|
||||
)),
|
||||
"gpt5.5-unrestricted" | "" => Ok((
|
||||
INSTRUCTION_FILENAME,
|
||||
INSTRUCTION_RELATIVE,
|
||||
INSTRUCTION_CONTENT,
|
||||
)),
|
||||
other => Err(CodexxError::Config(format!("未知指令提示词模板: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_managed_instruction_value(value: &str) -> bool {
|
||||
[INSTRUCTION_FILENAME, INSTRUCTION_54_FILENAME]
|
||||
.iter()
|
||||
@@ -1780,6 +2306,11 @@ fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> {
|
||||
.ok_or_else(|| CodexxError::Config(format!("{key} 不是 TOML table")))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_startup_diagnostics(config_dir: Option<String>) -> Result<StartupDiagnostics> {
|
||||
startup_diagnostics_inner(config_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_session_sync_status(
|
||||
config_dir: Option<String>,
|
||||
@@ -1796,6 +2327,11 @@ fn sync_sessions_provider(
|
||||
sync_sessions_provider_inner(config_dir, target_provider)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn sync_selected_sessions_provider(input: SelectedSessionSyncInput) -> Result<SessionSyncResult> {
|
||||
sync_selected_sessions_provider_inner(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_ccswitch_official_auth(db_path: Option<String>) -> Result<Option<OfficialAuthCandidate>> {
|
||||
read_ccswitch_official_auth_inner(db_path)
|
||||
@@ -1806,57 +2342,12 @@ fn import_ccswitch_codex_providers(db_path: Option<String>) -> Result<ImportResu
|
||||
import_ccswitch_codex_providers_inner(db_path)
|
||||
}
|
||||
|
||||
fn command_version(program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_codex_app_version() -> Option<String> {
|
||||
let output = Command::new("/usr/libexec/PlistBuddy")
|
||||
.args([
|
||||
"-c",
|
||||
"Print :CFBundleShortVersionString",
|
||||
"/Applications/Codex.app/Contents/Info.plist",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn macos_codex_app_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_codex_version() -> Option<String> {
|
||||
command_version("codex", &["--version"])
|
||||
.or_else(|| command_version("codex", &["-V"]))
|
||||
.or_else(macos_codex_app_version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_about_info(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
Ok(AboutInfo {
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
codex_version: detect_codex_version(),
|
||||
codex_version: platform::detect_codex_version(),
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
project_url: "https://github.com/yynxxxxx/Codex-X".to_string(),
|
||||
github_repo: "yynxxxxx/Codex-X".to_string(),
|
||||
@@ -1868,6 +2359,22 @@ fn list_saved_prompts() -> Result<Vec<SavedPrompt>> {
|
||||
list_saved_prompts_inner()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_builtin_prompt_status() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
builtin_prompt_status_inner()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn refresh_builtin_prompts() -> Result<Vec<BuiltinPromptStatus>> {
|
||||
refresh_builtin_prompts_inner()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remember_current_instruction(config_dir: Option<String>) -> Result<Option<SavedPrompt>> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
remember_current_instruction_prompt(&codex_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_prompt(prompt: SavedPrompt) -> Result<SavedPrompt> {
|
||||
let title = prompt.title.trim().to_string();
|
||||
@@ -1901,6 +2408,7 @@ fn delete_saved_prompt(id: String) -> Result<()> {
|
||||
fn enable_saved_prompt(config_dir: Option<String>, id: String) -> Result<ActionResult> {
|
||||
let prompt = get_saved_prompt_inner(id.trim())?;
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let _ = remember_current_instruction_prompt(&codex_dir);
|
||||
fs::create_dir_all(&codex_dir).map_err(|e| io_err(&codex_dir, e))?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let backup_id = create_backup(&codex_dir, "enable-custom-prompt")?;
|
||||
@@ -2057,8 +2565,9 @@ fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
}
|
||||
|
||||
fn enable_instruction_inner(config_dir: Option<String>, template_id: &str) -> Result<ActionResult> {
|
||||
let (filename, relative, content) = instruction_template(template_id)?;
|
||||
let (filename, relative, content, content_source) = builtin_prompt_content(template_id)?;
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let _ = remember_current_instruction_prompt(&codex_dir);
|
||||
fs::create_dir_all(&codex_dir).map_err(|e| io_err(&codex_dir, e))?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let backup_id = create_backup(&codex_dir, "enable-instruct")?;
|
||||
@@ -2070,13 +2579,13 @@ fn enable_instruction_inner(config_dir: Option<String>, template_id: &str) -> Re
|
||||
}
|
||||
doc["model_instructions_file"] = value(relative);
|
||||
|
||||
write_text(&codex_dir.join(filename), content)?;
|
||||
write_text(&codex_dir.join(filename), &content)?;
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
|
||||
let state = build_state(codex_dir)?;
|
||||
Ok(ActionResult {
|
||||
ok: true,
|
||||
message: format!("已启用 {filename}"),
|
||||
message: format!("已启用 {filename}(来源:{content_source})"),
|
||||
backup_id,
|
||||
state,
|
||||
})
|
||||
@@ -2288,37 +2797,53 @@ fn restore_backup(config_dir: Option<String>, backup_id: String) -> Result<Actio
|
||||
|
||||
#[tauri::command]
|
||||
fn open_url(url: String) -> std::result::Result<(), String> {
|
||||
let trimmed = url.trim();
|
||||
let trimmed = url.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err("URL 为空".to_string());
|
||||
}
|
||||
|
||||
let status = if cfg!(target_os = "macos") {
|
||||
Command::new("open").arg(trimmed).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", trimmed])
|
||||
.status()
|
||||
} else {
|
||||
Command::new("xdg-open").arg(trimmed).status()
|
||||
};
|
||||
// Do not wait for the browser process. On Windows, waiting for `cmd /C start` can
|
||||
// visibly freeze the WebView for a few seconds before the default browser appears.
|
||||
std::thread::spawn(move || {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = Command::new("open").arg(&trimmed).spawn();
|
||||
}
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
Ok(s) => Err(format!("打开失败,退出码: {:?}", s.code())),
|
||||
Err(e) => Err(format!("打开失败: {e}")),
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["/C", "start", ""])
|
||||
.arg(&trimmed)
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
{
|
||||
let _ = Command::new("xdg-open").arg(&trimmed).spawn();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_about_info,
|
||||
get_startup_diagnostics,
|
||||
get_session_sync_status,
|
||||
sync_sessions_provider,
|
||||
sync_selected_sessions_provider,
|
||||
read_ccswitch_official_auth,
|
||||
import_ccswitch_codex_providers,
|
||||
list_saved_prompts,
|
||||
get_builtin_prompt_status,
|
||||
refresh_builtin_prompts,
|
||||
remember_current_instruction,
|
||||
save_prompt,
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.18",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -19,7 +19,13 @@
|
||||
"minWidth": 1320,
|
||||
"minHeight": 720,
|
||||
"center": true,
|
||||
"resizable": true
|
||||
"resizable": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": {
|
||||
"x": 16,
|
||||
"y": 14
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+400
-69
@@ -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";
|
||||
@@ -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,24 @@ type SessionSyncResult = {
|
||||
backupDir: string;
|
||||
};
|
||||
|
||||
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[] = [
|
||||
@@ -719,6 +750,7 @@ function App() {
|
||||
const initialLang = (localStorage.getItem(LANG_KEY) as Lang | null) || "zh";
|
||||
const [lang, setLang] = React.useState<Lang>(initialLang === "en" ? "en" : "zh");
|
||||
const t = dict[lang];
|
||||
const isMacRuntime = navigator.userAgent.toLowerCase().includes("mac");
|
||||
const [tab, setTab] = React.useState<Tab>("dashboard");
|
||||
const [providerMode, setProviderMode] = React.useState<ProviderMode>("list");
|
||||
const [instructionMode, setInstructionMode] = React.useState<InstructionMode>("list");
|
||||
@@ -726,10 +758,16 @@ 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 [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
|
||||
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
|
||||
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("");
|
||||
@@ -739,12 +777,17 @@ function App() {
|
||||
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 promptUpdateCheckedRef = React.useRef(false);
|
||||
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";
|
||||
@@ -816,6 +859,40 @@ 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]);
|
||||
|
||||
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("");
|
||||
@@ -832,22 +909,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]);
|
||||
@@ -944,6 +1028,58 @@ 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 } = {}) => {
|
||||
setActionBusy("refreshPrompts");
|
||||
if (!quiet) 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),
|
||||
@@ -1039,6 +1175,15 @@ function App() {
|
||||
const restoreBackup = (backupId: string) =>
|
||||
call(() => invoke<ActionResult>("restore_backup", { configDir: configDir || null, backupId }), handleActionResult);
|
||||
|
||||
const openExternalUrl = React.useCallback((url?: string | null) => {
|
||||
if (!url) return;
|
||||
window.setTimeout(() => {
|
||||
void invoke("open_url", { url }).catch(() => {
|
||||
setToast(lang === "zh" ? "打开浏览器失败" : "Failed to open browser");
|
||||
});
|
||||
}, 0);
|
||||
}, [lang]);
|
||||
|
||||
const checkForUpdates = React.useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => {
|
||||
const repo = aboutInfo?.githubRepo || FALLBACK_GITHUB_REPO;
|
||||
const appVersion = aboutInfo?.appVersion || "0.0.0";
|
||||
@@ -1098,6 +1243,13 @@ function App() {
|
||||
void checkForUpdates({ quiet: true });
|
||||
}, [state, aboutInfo, checkForUpdates]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab !== "instruction" || promptUpdateCheckedRef.current) return;
|
||||
promptUpdateCheckedRef.current = true;
|
||||
void refreshBuiltinPrompts({ quiet: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
|
||||
const loadCcSwitchOfficialAuth = async (showToast = true) => {
|
||||
const candidate = await invoke<OfficialAuthCandidate | null>("read_ccswitch_official_auth", { dbPath: null });
|
||||
if (candidate) {
|
||||
@@ -1184,27 +1336,69 @@ 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");
|
||||
setStartupWizardOpen(false);
|
||||
};
|
||||
|
||||
const navItems: Array<[Tab, string, React.ReactNode]> = [
|
||||
["dashboard", t.nav.dashboard, <Layers3 size={18} />],
|
||||
@@ -1217,7 +1411,8 @@ function App() {
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<main className={cx("app-shell", isMacRuntime && "mac-shell")}>
|
||||
{isMacRuntime && <div className="window-drag-strip" data-tauri-drag-region />}
|
||||
<div className="orb orb-a" />
|
||||
<div className="orb orb-b" />
|
||||
|
||||
@@ -1287,7 +1482,7 @@ function App() {
|
||||
<div className="update-actions">
|
||||
<button className="primary-btn" onClick={() => {
|
||||
setUpdatePromptOpen(false);
|
||||
if (releaseInfo.htmlUrl) void invoke("open_url", { url: releaseInfo.htmlUrl });
|
||||
openExternalUrl(releaseInfo.htmlUrl);
|
||||
}}>
|
||||
<Download size={16} /> {lang === "zh" ? "现在下载" : "Download now"}
|
||||
</button>
|
||||
@@ -1299,6 +1494,54 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startupWizardOpen && startupDiagnostics && (
|
||||
<div className="startup-mask">
|
||||
<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 ? (
|
||||
<div className="panel glass center-panel">
|
||||
<Loader2 className="spin" />
|
||||
@@ -1315,7 +1558,7 @@ function App() {
|
||||
<strong>{lang === "zh" ? "发现新版本" : "New version found"}</strong>
|
||||
<p>{lang === "zh" ? `Codex-X ${releaseInfo.latestVersion || ""} 已发布` : `Codex-X ${releaseInfo.latestVersion || ""} is available`}</p>
|
||||
</div>
|
||||
<button className="secondary-btn small" onClick={() => releaseInfo.htmlUrl && void invoke("open_url", { url: releaseInfo.htmlUrl })}>
|
||||
<button className="secondary-btn small" onClick={() => openExternalUrl(releaseInfo.htmlUrl)}>
|
||||
{lang === "zh" ? "查看更新" : "View"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1466,7 +1709,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>
|
||||
@@ -1507,7 +1767,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>
|
||||
@@ -1528,11 +1788,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>
|
||||
@@ -1550,56 +1810,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-info-list">
|
||||
<div><span>CODEX_HOME</span><code>{sessionStatus?.codexDir || state.codexDir}</code></div>
|
||||
<div><span>{lang === "zh" ? "备份位置" : "Backup"}</span><code>{sessionStatus?.backupDir || `${state.codexDir}/backups_state/provider-sync`}</code></div>
|
||||
</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>
|
||||
@@ -1622,13 +1920,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>
|
||||
@@ -1636,12 +1953,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>
|
||||
@@ -1662,7 +1983,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>
|
||||
@@ -1676,7 +1997,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>
|
||||
@@ -1703,8 +2024,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>
|
||||
)}
|
||||
@@ -1740,8 +2061,8 @@ function App() {
|
||||
<div><span>{lang === "zh" ? "项目地址" : "Project"}</span><code>{aboutInfo?.projectUrl || `https://github.com/${FALLBACK_GITHUB_REPO}`}</code></div>
|
||||
</div>
|
||||
<div className="about-actions">
|
||||
<button className="secondary-btn" onClick={() => void invoke("open_url", { url: aboutInfo?.projectUrl || `https://github.com/${FALLBACK_GITHUB_REPO}` })}><ExternalLink size={16} /> {lang === "zh" ? "打开项目主页" : "Open project"}</button>
|
||||
<button className="ghost-btn" onClick={() => void invoke("open_url", { url: `${aboutInfo?.projectUrl || `https://github.com/${FALLBACK_GITHUB_REPO}`}/issues` })}><ExternalLink size={16} /> {lang === "zh" ? "反馈问题" : "Issues"}</button>
|
||||
<button className="secondary-btn" onClick={() => openExternalUrl(aboutInfo?.projectUrl || `https://github.com/${FALLBACK_GITHUB_REPO}`)}><ExternalLink size={16} /> {lang === "zh" ? "打开项目主页" : "Open project"}</button>
|
||||
<button className="ghost-btn" onClick={() => openExternalUrl(`${aboutInfo?.projectUrl || `https://github.com/${FALLBACK_GITHUB_REPO}`}/issues`)}><ExternalLink size={16} /> {lang === "zh" ? "反馈问题" : "Issues"}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1756,7 +2077,7 @@ function App() {
|
||||
</div>
|
||||
<div className="about-actions">
|
||||
<button className="primary-btn" onClick={() => void checkForUpdates()} disabled={releaseInfo.status === "checking"}><RefreshCw size={16} className={cx(releaseInfo.status === "checking" && "spin")} /> {lang === "zh" ? "检查更新" : "Check updates"}</button>
|
||||
<button className="secondary-btn" onClick={() => releaseInfo.htmlUrl && void invoke("open_url", { url: releaseInfo.htmlUrl })} disabled={!releaseInfo.htmlUrl}><Download size={16} /> {lang === "zh" ? "打开下载页" : "Open releases"}</button>
|
||||
<button className="secondary-btn" onClick={() => openExternalUrl(releaseInfo.htmlUrl)} disabled={!releaseInfo.htmlUrl}><Download size={16} /> {lang === "zh" ? "打开下载页" : "Open releases"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1781,6 +2102,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>
|
||||
)}
|
||||
|
||||
+321
-14
@@ -9,18 +9,22 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; }
|
||||
body { overflow: hidden; background: #070a13; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .58; }
|
||||
|
||||
.app-shell {
|
||||
--shell-pad-x: 18px;
|
||||
--shell-pad-bottom: 18px;
|
||||
--shell-pad-top: 18px;
|
||||
--shell-vertical-pad: calc(var(--shell-pad-top) + var(--shell-pad-bottom));
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
padding: var(--shell-pad-top) var(--shell-pad-x) var(--shell-pad-bottom);
|
||||
background:
|
||||
radial-gradient(circle at 12% 10%, rgba(110, 92, 255, .24), transparent 28%),
|
||||
radial-gradient(circle at 85% 22%, rgba(22, 193, 255, .18), transparent 34%),
|
||||
@@ -43,7 +47,7 @@ button:disabled { cursor: not-allowed; opacity: .58; }
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 36px);
|
||||
min-height: calc(100vh - var(--shell-vertical-pad));
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; margin-bottom: 28px; }
|
||||
.brand-mark {
|
||||
@@ -197,6 +201,7 @@ input, select, textarea {
|
||||
}
|
||||
.toast.ok { color: #dbfff3; background: rgba(12, 112, 81, .88); border: 1px solid rgba(31,242,178,.3); }
|
||||
.toast.error { color: #fff; background: rgba(157, 38, 63, .92); border: 1px solid rgba(255,120,145,.35); animation: none; }
|
||||
.app-shell.mac-shell .toast { top: calc(24px + var(--mac-titlebar-safe)); }
|
||||
@keyframes toast { 0% { opacity: 0; transform: translateY(-8px); } 12%, 84% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-8px); } }
|
||||
|
||||
.update-mask {
|
||||
@@ -673,13 +678,17 @@ html, body, #root {
|
||||
min-height: 720px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-shell.mac-shell {
|
||||
--mac-titlebar-safe: 44px;
|
||||
--shell-pad-top: calc(18px + var(--mac-titlebar-safe));
|
||||
}
|
||||
.sidebar {
|
||||
min-height: auto;
|
||||
height: calc(100vh - 36px);
|
||||
height: calc(100vh - var(--shell-vertical-pad));
|
||||
overflow: hidden;
|
||||
}
|
||||
.content {
|
||||
height: calc(100vh - 36px);
|
||||
height: calc(100vh - var(--shell-vertical-pad));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
@@ -715,7 +724,7 @@ html, body, #root {
|
||||
.provider-panel,
|
||||
.code-panel {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 36px);
|
||||
min-height: calc(100vh - var(--shell-vertical-pad));
|
||||
overflow: visible;
|
||||
}
|
||||
.provider-row-list {
|
||||
@@ -727,7 +736,7 @@ html, body, #root {
|
||||
overflow: visible;
|
||||
}
|
||||
.code-panel {
|
||||
min-height: calc(100vh - 36px);
|
||||
min-height: calc(100vh - var(--shell-vertical-pad));
|
||||
}
|
||||
.toml-preview {
|
||||
min-height: 520px;
|
||||
@@ -1056,7 +1065,7 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.instruction-panel {
|
||||
min-height: calc(100vh - 36px);
|
||||
min-height: calc(100vh - var(--shell-vertical-pad));
|
||||
}
|
||||
.instruction-row-list {
|
||||
display: flex;
|
||||
@@ -1445,11 +1454,18 @@ html, body, #root {
|
||||
|
||||
/* Session management page. */
|
||||
.sessions-panel {
|
||||
min-height: calc(100vh - 36px);
|
||||
min-height: calc(100vh - var(--shell-vertical-pad));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.panel.glass.sessions-panel {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
padding: 0;
|
||||
}
|
||||
.session-status-card {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr auto;
|
||||
@@ -1514,11 +1530,12 @@ html, body, #root {
|
||||
font-weight: 850;
|
||||
}
|
||||
.session-list-card {
|
||||
padding: 16px;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(4,10,24,.34);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.04);
|
||||
background: linear-gradient(180deg, rgba(6, 12, 27, .78), rgba(4,10,24,.58));
|
||||
border: 1px solid rgba(124,168,255,.16);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.055), 0 18px 46px rgba(0,0,0,.20);
|
||||
overflow: hidden;
|
||||
}
|
||||
.session-list-head {
|
||||
display: flex;
|
||||
@@ -1547,7 +1564,7 @@ html, body, #root {
|
||||
gap: 10px;
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
padding: 0 4px 2px 0;
|
||||
}
|
||||
.session-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -1695,3 +1712,293 @@ html, body, #root {
|
||||
padding-left: 54px;
|
||||
}
|
||||
}
|
||||
|
||||
/* macOS overlay titlebar: keep top chrome visually integrated with the app theme. */
|
||||
.window-drag-strip {
|
||||
position: fixed;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
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;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codex-x-workspace",
|
||||
"private": true,
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.18",
|
||||
"scripts": {
|
||||
"dev": "pnpm --dir apps/desktop tauri dev",
|
||||
"build": "pnpm --dir apps/desktop tauri build",
|
||||
|
||||
Executable
+81
@@ -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}"
|
||||
Reference in New Issue
Block a user