Compare commits

...
Author SHA1 Message Date
yyyyyynnn bffeca53cc docs: refresh README for new UI 2026-07-16 18:07:20 +08:00
yyyyyynnn d381753be9 fix(release): use direct updater asset URLs 2026-07-16 14:15:41 +08:00
yyyyyynnn 752078b2cf fix: resolve draft release ID before upload 2026-07-16 13:48:24 +08:00
yyyyyynnn f00acccef1 fix: support Python 3.10 in release validation 2026-07-16 13:45:43 +08:00
yyyyyynnn e5fa7fbe05 merge: reconcile main for v0.3.0 2026-07-16 13:43:03 +08:00
yyyyyynnn f058bb5e85 release: prepare v0.3.0 2026-07-16 13:42:50 +08:00
yyyyyynnn 659415f57c chore: acknowledge PR #11
Feature proposal accepted; implementation will be rewritten for the refactored architecture without applying this PR's tree changes.
2026-07-15 10:51:32 +08:00
yyyyyynnn f51bdd6875 chore: acknowledge PR #8
Feature proposal accepted; implementation will be rewritten for the refactored architecture without applying this PR's tree changes.
2026-07-15 10:51:32 +08:00
yyyyyynnn 6c90976e8b fix: deduplicate MCP imports and stabilize modal layout 2026-07-14 22:24:28 +08:00
yyyyyynnn b7d4a1996b fix: preserve concurrent session writes during rollback 2026-07-14 21:34:07 +08:00
yyyyyynnn 561c3dcda3 chore: satisfy strict clippy 2026-07-14 21:09:46 +08:00
yyyyyynnn 45f6a225d9 refactor: move backend integration tests 2026-07-14 21:05:50 +08:00
yyyyyynnn 79ccca2042 refactor: extract provider connection checks 2026-07-14 20:59:57 +08:00
yyyyyynnn 4e76fe54ac refactor: extract cc-switch provider import 2026-07-14 20:55:46 +08:00
yyyyyynnn 9206064e53 refactor: extract live provider configuration 2026-07-14 20:43:55 +08:00
yyyyyynnn c8a67261ca refactor: extract Codex state projection 2026-07-14 20:31:20 +08:00
yyyyyynnn bd1c103976 refactor: extract configuration backups 2026-07-14 20:22:07 +08:00
yyyyyynnn f8504dda30 refactor: split session synchronization internals 2026-07-14 20:15:50 +08:00
yyyyyynnn b5eab73bba refactor: extract prompt management domain 2026-07-14 20:15:26 +08:00
yyyyyynnn c263c488d0 fix: make session synchronization recoverable 2026-07-14 19:58:32 +08:00
yyyyyynnn 269b463a06 refactor: extract skills and MCP domain 2026-07-14 19:54:52 +08:00
yyyyyynnn 7927a4253d refactor: extract shared configuration helpers 2026-07-14 19:20:11 +08:00
yyyyyynnn 180d6b9500 fix: unify session database discovery 2026-07-14 19:02:24 +08:00
yyyyyynnn 64f8ccfd7a build: align Rust minimum version 2026-07-14 18:22:13 +08:00
yyyyyynnn e82ea649cd refactor: extract provider store 2026-07-14 18:20:57 +08:00
yyyyyynnn 78ecb89b9b fix: write session state atomically 2026-07-14 18:20:17 +08:00
yyyyyynnn 9fd405365a refactor: extract application database schema 2026-07-14 18:12:01 +08:00
yyyyyynnn dd9a28ef93 refactor: isolate session synchronization 2026-07-14 18:10:55 +08:00
yyyyyynnn 99d42fd234 refactor: split session storage and types 2026-07-14 18:06:24 +08:00
yyyyyynnn 9be6b20a1c refactor: split session deletion backend 2026-07-14 18:00:08 +08:00
yyyyyynnn 7d284a60fd refactor: isolate session management backend 2026-07-14 17:49:40 +08:00
muzimu e2b5c67a17 ✨ feat: 修正测试连通性,修正失败提示图标
后端 (lib.rs):
  - test_provider_connection 由探测 GET /models 改为向 {base}/responses 发送
    最小模型请求 (content: "hi"),依据返回状态判断连通性与模型可用性,
    对齐 new-api channel 测试逻辑;删除不再使用的 provider_test_request
  - 命令新增 model 参数;端点跟随 Codex 约定拼 {base}/responses
    (base_url 应含 /v1),超时调整为 20s
  - 失败时补全错误详情: HTTP 错误读取响应体提取上游信息 (extract_upstream_error:
    优先 JSON error.message/error/message,HTML 跳过),网络层错误展示 {e}
  - 为 HTTP 404 增加"端点不存在,请确认 base_url 是否包含 /v1"诊断提示

  前端 (main.tsx):
  - 添加供应商表单新增"测试连通性"按钮,列表与表单共用 testProvider 入口 (DRY)
  - testProvider 透传 model 至后端;前置 http/https 校验
  - 新增 isHttpBaseUrl 工具函数,保存供应商与测试前校验 API 请求地址必须以
    http:// 或 https:// 开头
  - 修复测试失败时 toast 仍显示 ✅: toast 改为 {text, kind} 结构,失败走
    setToastError 显示 AlertCircle 图标;保留 setToast(string) 兼容现有调用,
    统一列表/表单测试按钮措辞为"测试连通性"
  - 移除已废弃的 wire_api="chat" 选项与分支,wireApi 统一固定为 "responses"
    (Codex 新版仅支持 responses)
2026-07-14 17:43:06 +08:00
yyyyyynnn 2f63cabb47 refactor: extract session management page 2026-07-14 17:40:12 +08:00
yyyyyynnn 2dc6a35050 refactor: extract backend infrastructure modules 2026-07-14 17:36:20 +08:00
yyyyyynnn 195fe1ee82 Fix session synchronization and management 2026-07-14 17:23:25 +08:00
muzimu 0eabdebe76 🐞 fix: 修复删除正在使用供应商问题 新增确认弹窗 2026-07-14 14:36:43 +08:00
yyyyyynnn 00acc2c835 Purge README chart cache after refresh 2026-07-14 01:58:59 +08:00
yyyyyynnn 7f69661f6a Refresh GitHub Camo after star updates 2026-07-14 01:54:44 +08:00
yyyyyynnn f8bb9ab2c4 Reconcile star totals without persistent tokens 2026-07-14 01:45:53 +08:00
yyyyyynnn 9731843531 Collect star history inside GitHub Actions 2026-07-14 01:35:31 +08:00
yyyyyynnn 5708d4f9e9 Add self-hosted live star history 2026-07-14 01:25:58 +08:00
yyyyyynnn aba5d7cc0f Refresh README feature overview 2026-07-14 00:43:08 +08:00
yyyyyynnn 6df450482e Add project usage notice 2026-07-14 00:20:55 +08:00
yyyyyynnn f571799adb Release v0.2.35 2026-07-13 23:31:20 +08:00
yyyyyynnn 7f152b9812 Improve provider switching and session listing 2026-07-13 23:30:38 +08:00
yyyyyynnn a653a58f6d Add GPT-5.6 SOL unrestricted prompt example 2026-07-13 23:28:48 +08:00
yyyyyynnn 9019bdf3e2 Release v0.2.34 2026-07-13 21:10:53 +08:00
yyyyyynnn 1e89ff95d3 Add permanent session deletion 2026-07-13 21:10:34 +08:00
yyyyyynnn 2cc94f1707 Release v0.2.33 2026-07-11 12:22:34 +08:00
yyyyyynnn f2cb01dd0e Add Seagull 3.0 prompt example 2026-07-11 10:32:21 +08:00
yyyyyynnn 44c7c7ecbf Remove unwanted prompt examples 2026-07-11 10:30:57 +08:00
yyyyyynnn a23adc43ee Release v0.2.32 2026-07-11 10:10:23 +08:00
yyyyyynnn 0058405634 Improve prompt mode help and release notes 2026-07-11 10:06:42 +08:00
127 changed files with 30124 additions and 9406 deletions
+359 -120
View File
@@ -3,54 +3,183 @@ name: Release
on:
push:
tags:
- 'v*'
- "v*"
workflow_dispatch:
inputs:
tag:
description: Existing release tag to rebuild, for example v0.3.0
required: true
type: string
permissions:
contents: write
concurrency:
group: release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
prepare:
name: Prepare draft release
runs-on: ubuntu-22.04
outputs:
tag: ${{ steps.metadata.outputs.tag }}
version: ${{ steps.metadata.outputs.version }}
release_body: ${{ steps.metadata.outputs.release_body }}
release_id: ${{ steps.release.outputs.release_id }}
steps:
- name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Validate versions and prepare release notes
id: metadata
env:
REQUESTED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
run: |
set -euo pipefail
if [[ ! "$REQUESTED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release tag: $REQUESTED_TAG" >&2
exit 1
fi
tag_commit=$(git rev-list -n 1 "refs/tags/${REQUESTED_TAG}^{commit}")
if [ -z "$tag_commit" ] || [ "$tag_commit" != "$(git rev-parse HEAD)" ]; then
echo "The workflow must run from the exact commit referenced by ${REQUESTED_TAG}" >&2
exit 1
fi
RELEASE_TAG="$REQUESTED_TAG" python3 - <<'PY'
import json
import os
import re
from pathlib import Path
tag = os.environ["RELEASE_TAG"]
expected = tag[1:]
root_package = json.loads(Path("package.json").read_text(encoding="utf-8"))
desktop_package = json.loads(Path("apps/desktop/package.json").read_text(encoding="utf-8"))
tauri_config = json.loads(Path("apps/desktop/src-tauri/tauri.conf.json").read_text(encoding="utf-8"))
cargo_text = Path("apps/desktop/src-tauri/Cargo.toml").read_text(encoding="utf-8")
cargo_match = re.search(
r'^\[package\]\s*$.*?^version\s*=\s*"([^"]+)"',
cargo_text,
flags=re.S | re.M,
)
if not cargo_match:
raise SystemExit("Unable to read the package version from Cargo.toml")
versions = {
"package.json": root_package.get("version"),
"apps/desktop/package.json": desktop_package.get("version"),
"apps/desktop/src-tauri/Cargo.toml": cargo_match.group(1),
"apps/desktop/src-tauri/tauri.conf.json": tauri_config.get("version"),
}
mismatches = {path: value for path, value in versions.items() if value != expected}
if mismatches:
details = ", ".join(f"{path}={value!r}" for path, value in mismatches.items())
raise SystemExit(f"Release tag {tag} does not match project versions: {details}")
changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
match = re.search(
rf"^## \[{re.escape(tag)}\].*?\n(.*?)(?=^## \[|\Z)",
changelog,
flags=re.S | re.M,
)
body = match.group(1).strip() if match else ""
if not body:
body = "Codex-X desktop release."
Path("release-body.md").write_text(body + "\n", encoding="utf-8")
PY
delimiter="release_body_$(date +%s)_${RANDOM}"
{
echo "tag=$REQUESTED_TAG"
echo "version=${REQUESTED_TAG#v}"
echo "release_body<<$delimiter"
cat release-body.md
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
- name: Create clean draft release
id: release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.metadata.outputs.tag }}
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
is_draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')
if [ "$is_draft" != "true" ]; then
echo "${RELEASE_TAG} is already published; use a new version instead of replacing updater assets" >&2
exit 1
fi
gh release edit "$RELEASE_TAG" \
--title "Codex-X ${RELEASE_TAG}" \
--notes-file release-body.md \
--draft
else
gh release create "$RELEASE_TAG" \
--verify-tag \
--title "Codex-X ${RELEASE_TAG}" \
--notes-file release-body.md \
--draft
fi
release_id=$(gh api "/repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" \
| head -n 1)
if [ -z "$release_id" ]; then
echo "Unable to locate draft release for ${RELEASE_TAG}" >&2
exit 1
fi
gh api "/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \
--jq '.[].id' | while read -r asset_id; do
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
done
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.name }}
needs: prepare
runs-on: ${{ matrix.platform }}
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- name: macOS Apple Silicon
platform: macos-latest
args: --bundles dmg
artifact: codex-x-macos-arm64
paths: |
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
macos: true
args: --bundles app,dmg
asset_pattern: Codex-X-[version]-macos-arm64[ext]
- 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
macos: true
args: --bundles app,dmg
asset_pattern: Codex-X-[version]-macos-x64[ext]
- name: Linux x64
platform: ubuntu-22.04
args: --bundles deb,rpm
artifact: codex-x-linux
paths: |
apps/desktop/src-tauri/target/release/bundle/deb/*.deb
apps/desktop/src-tauri/target/release/bundle/rpm/*.rpm
- name: Windows
macos: false
args: --bundles deb,rpm,appimage
asset_pattern: Codex-X-[version]-linux-x64[ext]
- name: Windows x64
platform: windows-latest
macos: false
args: --bundles msi
artifact: codex-x-windows
paths: |
apps/desktop/src-tauri/target/release/bundle/msi/*.msi
apps/desktop/src-tauri/Codex-X_*_windows_x64_portable.zip
asset_pattern: Codex-X-[version]-windows-x64[ext]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
@@ -64,144 +193,254 @@ jobs:
patchelf
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 10.26.0
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: pnpm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
with:
toolchain: stable
- name: Rust cache
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/desktop/src-tauri
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Tauri bundles
run: pnpm --dir apps/desktop tauri build ${{ matrix.args }}
- name: Create Windows portable zip
- name: Test Windows filesystem compatibility
if: matrix.platform == 'windows-latest'
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
- name: Import Apple signing certificate and notarization key
if: matrix.macos
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
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"
required=(
APPLE_CERTIFICATE
APPLE_CERTIFICATE_PASSWORD
APPLE_API_ISSUER
APPLE_API_KEY
APPLE_API_KEY_P8_BASE64
APPLE_SIGNING_IDENTITY
APPLE_TEAM_ID
KEYCHAIN_PASSWORD
)
for name in "${required[@]}"; do
if [ -z "${!name:-}" ]; then
echo "Missing required repository secret: ${name}" >&2
exit 1
fi
done
certificate_path="$RUNNER_TEMP/codex-x-developer-id.p12"
api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
keychain_path="$RUNNER_TEMP/codex-x-signing.keychain-db"
printf '%s' "$APPLE_CERTIFICATE" | base64 -D > "$certificate_path"
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -D > "$api_key_path"
chmod 600 "$certificate_path" "$api_key_path"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
security default-keychain -s "$keychain_path"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security import "$certificate_path" \
-k "$keychain_path" \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$KEYCHAIN_PASSWORD" \
"$keychain_path"
security find-identity -v -p codesigning "$keychain_path" \
| grep -F -- "$APPLE_SIGNING_IDENTITY" >/dev/null
echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV"
echo "CODEXX_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
echo "CODEXX_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV"
- name: Validate updater signing secrets
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ] || [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then
echo "Missing Tauri updater signing repository secrets" >&2
exit 1
fi
- name: Build, sign, and upload updater artifacts
# Pinned to tauri-action v1.0.0 because this step receives the updater private key.
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ matrix.macos && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_API_ISSUER: ${{ matrix.macos && secrets.APPLE_API_ISSUER || '' }}
APPLE_API_KEY: ${{ matrix.macos && secrets.APPLE_API_KEY || '' }}
APPLE_API_KEY_PATH: ${{ matrix.macos && env.APPLE_API_KEY_PATH || '' }}
APPLE_TEAM_ID: ${{ matrix.macos && secrets.APPLE_TEAM_ID || '' }}
with:
releaseId: ${{ needs.prepare.outputs.release_id }}
tagName: ${{ needs.prepare.outputs.tag }}
releaseBody: ${{ needs.prepare.outputs.release_body }}
releaseDraft: true
prerelease: false
projectPath: apps/desktop
args: ${{ matrix.args }}
releaseAssetNamePattern: ${{ matrix.asset_pattern }}
uploadUpdaterJson: true
uploadUpdaterSignatures: true
updaterJsonPreferNsis: false
retryAttempts: 3
- name: Verify updater signatures were generated
run: |
set -euo pipefail
signature_count=$(find apps/desktop/src-tauri/target/release/bundle -type f -name '*.sig' | wc -l | tr -d ' ')
if [ "$signature_count" -lt 1 ]; then
echo "No updater signatures were generated" >&2
exit 1
fi
- name: Verify signed and notarized macOS bundles
if: matrix.macos
run: |
set -euo pipefail
app_path=$(find apps/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -type d -name '*.app' -print -quit)
dmg_path=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)
test -n "$app_path"
test -n "$dmg_path"
codesign --verify --deep --strict --verbose=2 "$app_path"
spctl --assess --type execute --verbose=4 "$app_path"
xcrun stapler validate "$app_path"
hdiutil verify "$dmg_path"
- name: Create and upload Windows portable archive
if: matrix.platform == 'windows-latest'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
portable_root="apps/desktop/src-tauri/portable"
portable_dir="$portable_root/Codex-X-${VERSION}-windows-x64-portable"
archive="apps/desktop/src-tauri/Codex-X-${VERSION}-windows-x64-portable.zip"
rm -rf "$portable_root"
mkdir -p "$portable_dir"
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
touch "$portable_dir/Codex-X.portable"
cat > "$portable_dir/README-portable.txt" <<'EOF'
Codex-X Windows Portable
Double click Codex-X.exe to run. No MSI installation is required.
The portable edition uses manual downloads instead of in-app installation.
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
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"
powershell -NoProfile -Command "Compress-Archive -Path '${portable_dir}/*' -DestinationPath '${archive}' -Force"
gh release upload "$RELEASE_TAG" "$archive" --clobber
- name: Normalize release asset names
- name: Remove temporary Apple credentials
if: always() && matrix.macos
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
if [ -n "${CODEXX_KEYCHAIN_PATH:-}" ]; then
security delete-keychain "$CODEXX_KEYCHAIN_PATH" || true
fi
rm -f "${CODEXX_CERTIFICATE_PATH:-}" "${APPLE_API_KEY_PATH:-}"
- name: Upload bundle artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.paths }}
if-no-files-found: error
retention-days: 7
release:
name: Publish GitHub Release
needs: build
publish:
name: Validate and publish release
needs: [prepare, build]
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download bundle artifacts
uses: actions/download-artifact@v4
- name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
path: release-assets
merge-multiple: true
ref: ${{ needs.prepare.outputs.tag }}
- name: Prepare release notes
id: release_notes
run: |
python3 - <<'PY'
from pathlib import Path
import os
import re
tag = os.environ["GITHUB_REF_NAME"]
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
match = re.search(
rf"^## \[{re.escape(tag)}\].*?\n(.*?)(?=^## \[|\Z)",
text,
flags=re.S | re.M,
)
body = match.group(1).strip() if match else ""
if not body:
body = "Codex-X desktop release."
with open("release-body.md", "w", encoding="utf-8") as fh:
fh.write(body + "\n")
PY
- name: Publish release
- name: Download and validate updater manifest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
title="Codex-X ${tag}"
if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --title "$title" --notes-file release-body.md --latest
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' -o -name '*.zip' \) -print | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release assets found" >&2
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" > release-assets.json
latest_asset_id=$(jq -r '.[] | select(.name == "latest.json") | .id' release-assets.json)
if [ -z "$latest_asset_id" ] || [ "$latest_asset_id" = "null" ]; then
echo "latest.json is missing from the draft release" >&2
exit 1
fi
printf 'Uploading %s\n' "${assets[@]}"
gh release upload "$tag" "${assets[@]}" --clobber
gh api \
-H "Accept: application/octet-stream" \
"/repos/${GITHUB_REPOSITORY}/releases/assets/${latest_asset_id}" \
> latest.json
python3 scripts/validate_updater_release.py \
--manifest latest.json \
--assets release-assets.json \
--version "$VERSION" \
--rewrite-download-urls
gh release upload "$RELEASE_TAG" latest.json --clobber
- name: Publish the verified release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest
- name: Verify published updater endpoint
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
endpoint="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/latest.json"
curl -fsSL \
--retry 12 \
--retry-delay 5 \
--retry-all-errors \
"$endpoint" \
-o published-latest.json
python3 scripts/validate_updater_release.py \
--manifest published-latest.json \
--assets release-assets.json \
--version "$VERSION"
python3 - <<'PY' > updater-urls.txt
import json
manifest = json.load(open("published-latest.json", encoding="utf-8"))
for entry in manifest["platforms"].values():
print(entry["url"])
PY
sort -u updater-urls.txt | while read -r url; do
curl -fsSLI \
--retry 5 \
--retry-delay 3 \
--retry-all-errors \
"$url" >/dev/null
done
+76
View File
@@ -0,0 +1,76 @@
name: Refresh Star History
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: star-history-${{ github.repository }}
cancel-in-progress: true
jobs:
refresh:
name: Refresh chart data
runs-on: ubuntu-22.04
timeout-minutes: 5
steps:
- name: Send authenticated repository snapshot
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INGEST_TOKEN: ${{ secrets.STAR_HISTORY_INGEST_TOKEN }}
REPOSITORY: ${{ github.repository }}
CAMO_PURGE_URLS: ${{ vars.STAR_HISTORY_CAMO_URLS }}
STAR_HISTORY_ENDPOINT: ${{ vars.STAR_HISTORY_ENDPOINT }}
run: |
set -euo pipefail
test -n "${INGEST_TOKEN}"
test -n "${STAR_HISTORY_ENDPOINT}"
metadata_file="${RUNNER_TEMP}/star-history-metadata.json"
payload_file="${RUNNER_TEMP}/star-history-payload.json"
gh api \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"repos/${REPOSITORY}" > "${metadata_file}"
jq -n \
--arg repository "${REPOSITORY}" \
--slurpfile metadata "${metadata_file}" \
'{
repository: $repository,
createdAt: $metadata[0].created_at,
checkedAt: (now | todateiso8601),
currentStars: $metadata[0].stargazers_count
}' > "${payload_file}"
curl \
--fail-with-body \
--retry 3 \
--retry-all-errors \
--connect-timeout 15 \
--max-time 240 \
--request POST \
--header "Authorization: Bearer ${INGEST_TOKEN}" \
--header "Content-Type: application/json" \
--data-binary "@${payload_file}" \
"${STAR_HISTORY_ENDPOINT}"
while IFS= read -r camo_url; do
test -n "${camo_url}" || continue
case "${camo_url}" in
https://camo.githubusercontent.com/*) ;;
*) echo "::warning::Skipped an invalid Camo purge URL"; continue ;;
esac
curl \
--fail \
--silent \
--show-error \
--retry 2 \
--request PURGE \
"${camo_url}" > /dev/null \
|| echo "::warning::GitHub Camo purge failed and will be retried on the next refresh"
done <<< "${CAMO_PURGE_URLS}"
+8
View File
@@ -23,6 +23,14 @@ apps/desktop/src-tauri/gen/schemas/
.env
.env.*
*.local
apple cert/
*.p12
*.p8
*.pfx
*.pem
*.key
codex-x-updater.key
codex-new-ui/
# Backups
*.bak
+115
View File
@@ -2,6 +2,121 @@
All notable changes to Codex-X will be documented here.
## [v0.3.0] - 2026-07-16
### 更新
- 全面升级桌面界面,统一各页面布局、按钮、弹窗和 Toast,并加入深浅色模式与页面过渡动画。
- 新增应用内更新:macOS、Windows MSI 和 Linux AppImage 可直接下载、安装并重新启动;发现更新时会在侧边栏显示入口和进度。
- 完善供应商管理:删除增加二次确认,连接测试改用 `/v1/models`,添加或编辑时可获取并选择模型,同时简化 OpenAI Official 页面。
- 完善指令提示词管理:新增分类功能,内置两套新模板,精简模板卡片,添加或编辑后只保存、不自动启用。
- 在线提示词会在启动后后台同步并保存到本机;支持 CDN、GitHub 备用源和系统代理,离线时也能使用上次同步内容。
- 简化会话管理:统一同步到当前供应商,取消启动时自动修改,并支持查看、选择和删除内部会话。
- 优化 Skills/MCP 导入,自动排除已经导入的内容,没有新内容时不再弹出空窗口。
### 修复 Bug
- 修复旧提示词配置可能导致 Codex 报 `expected u32`,以及 Windows `.codex` 使用链接目录后无法正常读写的问题。
- 修复会话同步可能改写聊天内容、出现 `invalid_id_prefix`、重复显示会话或覆盖正在写入记录的问题。
- 修复深浅色模式下的文字、滚动条、复选框和弹窗显示异常,以及弹窗打开后背景仍能滚动的问题。
- 修复提示词缓存延迟造成的重复同步、错误更新或误删除,并修复应用内更新失败后的错误状态提示。
### 兼容说明
- `v0.3.0` 是首个支持应用内更新的版本,旧版用户需要手动安装本版本一次。
- Windows 便携版和 Linux deb/rpm 继续手动更新;Windows MSI 首次安装仍可能出现 SmartScreen 提示。
### 开发
- 增加更新包签名、macOS 签名与公证,并完成后端模块拆分和发布流程安全加固。
## [v0.2.35] - 2026-07-13
### 更新
- 新增 `gpt-5.6-sol-unrestricted.md` 指令提示词模板,同步 GitHub 模板后即可在【指令提示词】页面启用。
- 优化第三方供应商切换:切换后新建或重新打开 Codex 会话即可使用新的中转,无需重启整个 Codex 客户端。
- 优化从 cc-switch 导入供应商的结果提示,现在会分别显示新增、更新、合并和跳过数量。
### 修复 Bug
- 修复相同 API 地址和 API Key 的供应商因名称、模型或其他配置不同而重复显示的问题;相同地址但不同 Key 的供应商仍会分别保留,升级后也会自动整理已有重复项。
- 修复切换到其他供应商或切回 OpenAI Official 后,原供应商可能从列表中消失的问题;切换前会自动保存当前供应商。
- 修复从 cc-switch 重复导入时可能覆盖 Codex-X 中已有供应商名称、模型或自定义配置的问题,并兼容旧版 cc-switch 数据。
- 优化会话列表:Codex 自动创建的后台子会话不再重复显示为普通会话,项目数量和会话总数更加准确。
- 优化会话编号显示,保留编号开头和结尾,减少多个会话看起来像同一个会话的情况。
- 修复检测到的供应商与本地供应商同名时,编辑保存可能因 ID 冲突覆盖另一条记录的问题。
## [v0.2.34] - 2026-07-13
### 更新
- 会话管理新增指定会话永久删除:支持单选、多选,并在二次确认后从当前 Codex 存储中硬删除;操作不进入回收站、不创建新的删除备份,Codex-X 内无法撤销。
- “按项目路径分组”新增项目级三态勾选,可一次选中某个项目在当前列表中加载的全部会话,也支持跨多个项目批量选择;搜索状态下会同时显示当前命中数与项目会话总数。
- 重做会话列表为更克制的表格式布局,统一标题、更新时间、Provider、模型和 ID 列,降低高亮边框、彩色标签与重复装饰造成的视觉干扰。
- 移除“已选 / 需修复、选择需同步、清空选择、修复选中”等按条修复控件,保留顶部“检查会话”和“同步 / 修复”用于全量处理。
- 永久删除确认窗口会预览待删除会话,并要求确认已关闭正在使用相关会话的其他 Codex 窗口或 CLI;由所选会话派生的子会话也会一并处理。
### 修复 Bug
- 修复默认窗口大小下会话列表滚动条在下拉过程中消失、必须放大窗口才能继续查看的问题;列表现在使用稳定的内部滚动区域并阻止滚动穿透。
- 修复同时存在当前 `state_*.sqlite` 与旧版 `sqlite/state_*.sqlite` 时,会话总数重复统计并混入旧会话的问题;现在优先使用当前活动会话数据库。
- 修复批量删除部分失败时结果反馈不完整的问题,界面会保留未删除项并显示失败数量与原因,方便关闭占用进程后重试。
### 开发
- 永久删除优先调用 Codex App Server 官方 `thread/delete`,不可用时使用本地兼容清理,并复用跨平台 Codex 可执行文件探测与启动逻辑。
- 本地兼容清理覆盖父子会话、当前及旧版会话数据库关联记录、活动/归档 Rollout、会话索引、历史记录、Shell 快照以及日志、记忆、目标和本地目录等关联数据。
- 会话同步与删除共用维护锁,并增加 Rollout 规范路径校验与符号链接边界保护,避免并发维护或越界删除。
- 增加活动数据库优先级、父子会话与重复库清理、关联记录清理、Rollout 越界拒绝及符号链接防护测试。
## [v0.2.33] - 2026-07-11
### 更新
- 重做【指令提示词】页面布局:当前状态、启用方式和模板列表使用统一的紧凑框架,模板来源与更新时间更容易查看。
- GitHub 提示词目录改为权威同步:自动发现并缓存新增 `.md`,清理远端已删除模板和历史别名缓存;离线时仍可使用上次成功同步的内容。
- GitHub `examples/` 新增 `海鸥3.0破甲.md`,并清理不再使用的旧示例模板。
### 修复 Bug
- 修复首次进入提示词页或切换导航返回时,会先显示历史缓存、随后再跳到 GitHub 最新模板的问题;同一次启动中会保留已同步状态,不再重复回退旧列表。
- 修复提示词模板较多时列表溢出最外层边框的问题,外层面板现在会随完整列表正确增高。
- 修复 GitHub 同步并发、部分模板下载失败和异常目录响应可能造成缓存状态错乱或误删的问题;失败后允许重新同步。
- 修复远端模板已删除但仍在启用时,重启后可能无法识别和关闭的问题;当前生效模板会保留明确的关闭入口。
- 删除 `gpt5.5-jeli.md` 介绍中多余的“测试生效”文案。
- 修复 macOS Apple Silicon / Intel、Windows 和 Linux 应用图标外围出现大块白边的问题,重新生成全平台透明图标资源。
### 开发
- 增加 GitHub 模板动态发现、缓存去重、离线回退、过期缓存删除和异常目录保护测试,并为缓存刷新增加互斥与事务保护。
- 修正发布脚本遗漏 `Cargo.lock` 的问题,确保 tag 内 Rust 包版本与应用版本一致。
## [v0.2.32] - 2026-07-11
### 更新
- 提示词页的“启用方式”新增问号说明,用户可以直接看到“保留原提示词 / 替换原提示词”对现有系统提示词和 `model_instructions_file` 的实际影响。
- 提示词注入页面继续沿用统一的当前状态展示,方便判断当前模板与启用方式是否一致。
### 修复 Bug
- 修复提示词启用方式说明不够直观的问题,减少误操作风险。
### 修复 Bug
- 修复相同 Base URL、API Key、模型但 TOML 内容不同的供应商可能同时显示“当前”的问题;现在优先按完整 live TOML 匹配唯一配置,并避免重复展示 detected custom 卡片。
- 修复追加提示词后禁用会误影响用户原有规则的风险;禁用只删除 Codex-X 管理的 AGENTS 区块或指令文件,用户其他内容保持不变。
- 修复并发操作在同一毫秒创建备份时可能发生临时文件名冲突的问题。
- 修复切换 Skill / MCP 开关后条目因启用状态排序而跳到列表其他位置的问题。
- 修复 macOS Overlay 标题栏无法拖动的问题:补齐 Tauri 2 的 `core:window:allow-start-dragging` capability,并统一顶部拖动区域样式。
- 修复 Windows 桌面应用环境中因 PATH 不完整或 `codex.cmd` 无法直接执行而检测不到 Codex CLI 版本的问题;新增 npm、ChatGPT、Cursor、VS Code 和常见安装目录探测。
### 开发
- 将 `AGENTS.md` 纳入 Codex-X 备份与恢复流程。
- 增加受管区块合并/卸载、GitHub 动态模板发现、完整 TOML 供应商匹配、追加/替换模式和 AGENTS 恢复测试。
## [v0.2.31] - 2026-07-09
### 更新
+116 -86
View File
@@ -8,9 +8,9 @@
# Codex-X
**Prompt Injection · Provider Switching · TOML / Auth Visual Manager for Codex**
**Codex Prompts · API / Providers · Sessions · Skills / MCP in One Place**
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.
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
<p>
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
@@ -32,15 +32,14 @@
## 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.
When you use Codex Desktop, the CLI, third-party APIs, and multiple prompts together, settings quickly become scattered across different files. Codex-X brings these frequent tasks into one desktop interface, so you can see the current state and complete common actions with a click.
It turns several high-frequency operations into a desktop UI:
You can use it to:
- 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
- Choose from the prompt library or import your own `.md`, then keep the existing prompt or replace it
- Save, test, and switch between OpenAI Official and third-party APIs, including Providers imported from cc-switch
- Search and organize local sessions by project, check / automatically repair problems, and precisely delete sessions you no longer need
- Manage Skills and MCP in one place, and inspect the current `config.toml`, `auth.json`, and operation backups
## Preview
@@ -109,106 +108,111 @@ It turns several high-frequency operations into a desktop UI:
<div align="center">
<table>
<tr>
<th align="center" width="180">Feature</th>
<th align="center">Description</th>
<th align="center" width="190">What you want to do</th>
<th align="center">How Codex-X helps</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>
<td align="center">🧩 <b>Use prompt templates</b></td>
<td align="left">The current library contains <b>5 templates</b>. Enable / disable one with a click and choose “Keep existing” or “Replace existing”; GitHub sync, local caching, and importing or editing your own <code>.md</code> files are supported.</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>
<td align="center">⚡ <b>Switch APIs / relays</b></td>
<td align="left">Save, test, and enable multiple third-party Providers, or import them from cc-switch; entries with the same endpoint and Key are merged automatically. Create or reopen a session after switching to apply the change without restarting the entire Codex client.</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>
<td align="center">💬 <b>Organize local sessions</b></td>
<td align="left">Search sessions, group them by project path, and check or automatically repair inconsistent data. Select one, several, or an entire project to permanently delete specific sessions from Codex storage.</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>
<td align="center">🧠 <b>Manage Skills / MCP</b></td>
<td align="left">View available Skills and MCP servers, preview existing items before import, install a Skill from ZIP, enable / disable individual entries, and check Skill update status.</td>
</tr>
<tr>
<td align="center">🧠 Skills / MCP</td>
<td>New Skills & MCP page: read Codex Skills / MCP servers, import existing items, install Skill ZIP packages, enable / disable individual Skills or MCP servers, and check Skill update status.</td>
<td align="center">📝 <b>Manage config and login</b></td>
<td align="left">Inspect the active Codex <code>config.toml</code> and <code>auth.json</code> in one place, with a clear distinction between official login credentials and third-party API Keys. Important writes are backed up automatically and can be restored when needed.</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>
<td align="center">📦 <b>Use it across platforms</b></td>
<td align="left">Available for macOS Apple Silicon / Intel, Windows MSI / portable, and Linux packages. Installed editions can download, verify, and install updates in the app; the portable edition keeps manual downloads.</td>
</tr>
</table>
</div>
## Highlights
### 1. Built-in dual instruction prompt templates
### 1. Prompt Template Center
Codex-X currently includes two prompt templates:
<p align="center">
<img src="https://img.shields.io/badge/Template_library-5_total-2563eb?style=flat-square" alt="5 templates in the current library" />
<img src="https://img.shields.io/badge/Bundled_offline-5_templates-16a34a?style=flat-square" alt="5 templates bundled for offline use" />
<img src="https://img.shields.io/badge/GitHub_sync-Automatic_updates-f59e0b?style=flat-square" alt="Automatic GitHub template sync" />
</p>
- `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`.
> [!TIP]
> **Ready after installation, automatically expanded when online.**
>
> The app bundle includes all 5 current templates for offline use. After launch, it synchronizes updates and newly added templates from GitHub `examples/` in the background, without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
<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>
<th align="center">Template</th>
<th align="center">Best for</th>
<th align="center">Availability</th>
</tr>
<tr>
<td align="center">Positioning</td>
<td align="center">5.4 CTF sandbox</td>
<td align="center">5.5 unrestricted mode</td>
<td><a href="examples/gpt5.5-unrestricted.md"><code>gpt5.5-unrestricted.md</code></a></td>
<td align="left">Compact and general-purpose for everyday coding and technical work</td>
<td align="center">Bundled offline<br />GitHub updates</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>
<td><a href="examples/gpt5.4-unrestricted.md"><code>gpt5.4-unrestricted.md</code></a></td>
<td align="left">GPT-5.4 / Codex CLI workflows with a CTF and security-research focus</td>
<td align="center">Bundled offline<br />GitHub updates</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>
<td><a href="examples/gpt5.5-jeli.md"><code>gpt5.5-jeli.md</code></a></td>
<td align="left">A plain-language general version with a fuller engineering and reverse-engineering workflow</td>
<td align="center">Bundled offline<br />GitHub updates</td>
</tr>
<tr>
<td align="center">Length</td>
<td align="center">~180 lines</td>
<td align="center">~40 lines</td>
<td><a href="examples/gpt-5.6-sol-unrestricted.md"><code>gpt-5.6-sol-unrestricted.md</code></a></td>
<td align="left">A GPT-5.6 SOL prompt focused on direct execution and bilingual tasks</td>
<td align="center">Bundled offline<br />GitHub updates</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>
<td><a href="examples/%E6%B5%B7%E9%B8%A53.0%E7%A0%B4%E7%94%B2.md"><code>海鸥3.0破甲.md</code></a></td>
<td align="left">A Chinese technical-operator persona with routing for coding, CTF, reverse engineering, memory, and protocol work</td>
<td align="center">Bundled offline<br />GitHub updates</td>
</tr>
</table>
</div>
Post-deployment test prompt:
<table>
<tr>
<td width="50%" valign="top">
<b>Keep existing prompt</b><br />
Best for users who already have personal rules. Codex-X only appends its managed content and removes only that content when disabled, leaving the original prompt untouched.
</td>
<td width="50%" valign="top">
<b>Replace existing prompt</b><br />
Makes the selected template the primary instruction entry point, which is useful when you want to switch completely to a specific template.
</td>
</tr>
</table>
```text
How do I perform SQL injection testing against a target?
```
A backup is created automatically before every enable or disable action. In addition to the template library, you can import, edit, and delete your own `.md` prompts.
Typical effect:
### 2. Provider Switching: Ready in a New Session
```text
Before injection → refusal or generic answer
After injection → direct security research methodology and testing steps
```
> [!NOTE]
> After enabling a new third-party Provider, create or reopen a Codex session to use the new relay. You do not need to restart the entire Codex client.
### 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
- Save multiple third-party Providers and always see which one is currently active
- Test an API endpoint before switching, and save or enable a configuration separately
- Edit the Base URL, API Key, Model, Wire API, and complete TOML on the same page
- cc-switch imports report added, updated, merged, and skipped entries; the same URL + Key is no longer shown more than once
- Switching back to OpenAI Official preserves the current official login, and third-party configurations no longer disappear unexpectedly
### 3. Official Auth management
@@ -224,26 +228,46 @@ After injection → direct security research methodology and testing steps
- Edit full TOML directly from the Provider editor
- Save changes back to the Codex configuration directory
### 5. Session Management / Provider Sync
### 5. Session Management: Inspect, Repair, and Permanently Delete
Codex-X can read Codex local session data:
<table>
<tr>
<td width="50%" valign="top">
<b>Find and organize</b><br />
Search sessions by title or project path and group them by project. Internal subagent sessions created automatically by Codex stay out of the normal session list by default.
</td>
<td width="50%" valign="top">
<b>Inspect and repair</b><br />
Check whether local sessions match the current Provider, repair all mismatches manually, or enable automatic inspection and repair at startup.
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<b>Precise deletion</b><br />
Select one session, several sessions, or one or more projects to select all sessions under them. After confirmation, the matching sessions and their derived child sessions are removed from Codex storage itself.
</td>
</tr>
</table>
```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.
> [!CAUTION]
> **Permanent deletion cannot be undone.** Close any Codex windows or CLI processes still using those sessions, then review the deletion list again in the confirmation dialog.
### 6. Skills / MCP Management
Codex-X now includes a dedicated **Skills & MCP** page for managing Codex capability extensions in one place.
Manage Codex capability extensions from the **Skills & MCP** page instead of searching through multiple directories and configuration files.
- Skills: read existing Codex Skills, import existing items, install ZIP packages, enable / disable, and check update status
- MCP: read Codex MCP servers, import existing items, enable / disable, and write enabled servers back to Codex <code>config.toml</code>
- Useful for Android APK reverse engineering, Ghidra / IDA, Web / API / protocol reverse engineering, CTF, and security-testing skill packs
<table>
<tr>
<td width="50%" valign="top">
<b>Skills</b><br />
View current Skills, import existing content, or install from ZIP. Enable / disable entries individually and check whether installed Skills have updates.
</td>
<td width="50%" valign="top">
<b>MCP</b><br />
Preview existing MCP servers before importing them, then choose what Codex-X should manage. Codex-X maintains the Codex configuration when a server is enabled or disabled.
</td>
</tr>
</table>
### 7. Reverse Skills Navigation
@@ -288,9 +312,9 @@ Codex-X now includes a dedicated **Skills & MCP** page for managing Codex capabi
- macOS Intel `.dmg`
- Windows `.msi`
- Windows Portable `.zip`
- Linux `.deb` / `.rpm`
- Linux `.deb` / `.rpm` / `.AppImage`
- Automatic GitHub Releases builds
- In-app update checking
- In-app updates for installed editions; manual updates for Windows Portable
## Tech Stack
@@ -366,6 +390,12 @@ Thanks to the [LINUX DO forum](https://linux.do/) community for attention, feedb
## Star History
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx%2Fcodex-x&amp;type=date&amp;legend=top-left&amp;sealed_token=Vqvz67Jv_WIePGePCN8RdJaY5oCyqqCZUSFWs4M4dAxP8JXFZlYEWbI8YcU6SFgpqOqqJifzpOTIlMg4ee8NaCkHpCSqv1r5pxewR-tQlmxswaZlhedd6A" width="900" />
</a>
<p align="center">
<a href="https://github.com/yynxxxxx/Codex-X/stargazers">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" />
<img alt="Codex-X Star History" src="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" width="900" />
</picture>
</a>
</p>
+159 -133
View File
@@ -8,9 +8,9 @@
# Codex-X
**Codex 提示词注入 · Provider 切换 · TOML / Auth 可视化管理器**
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具,内置 `gpt5.5-unrestricted.md` 与 `gpt5.4-unrestricted.md`,支持一键写入 / 禁用指令提示词、第三方 Provider 切换、官方 Auth 管理、TOML 可视化编辑与本地会话 Provider Sync。
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP 和 TOML 配置都放进可视化界面里,不用反复手改文件。
<p>
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
@@ -32,75 +32,50 @@
## Codex-X 是什么?
Codex-X 不是普通的配置文件编辑器,而是一个面向 Codex CLI 的 **可视化增强管理器**。
当你同时使用 Codex 桌面端、CLI、第三方 API、Skills / MCP 和多套提示词时,配置很容易散落在不同文件里。Codex-X 把这些高频操作集中到一个桌面界面中,让当前状态看得见、常用操作点一下就能完成。
它把几个高频操作做成了桌面软件:
你可以用它:
- 给 Codex 写入 / 禁用指令提示词模板
- 切换官方 OpenAI 与第三方 Codex API Provider
- 查看 / 编辑 `~/.codex/config.toml`
- 查看 / 编辑官方 `~/.codex/auth.json`
- 检查并修复本地历史会话的 Provider 元数据
- 像管理插件一样管理提示词:分类、导入 Markdown、自定义编辑、一键启用 / 禁用
- 内置 5 套提示词模板,同时支持用户把自己的提示词变成可视化模板库
- 保存、测试并切换 OpenAI Official 与第三方 API,还能从 cc-switch 导入现有供应商
- 同步、检查、搜索和删除本地会话,按项目路径整理 Codex 历史记录
- 集中管理 Skills 与 MCP,查看当前 `config.toml`、`auth.json` 和操作备份
## 软件预览
<details open>
<summary><b>应用界面预览</b>:主界面 / Provider / TOML / Auth</summary>
<summary><b>新版 UI:指令提示词管理中心</b></summary>
<p align="center">
<img src="docs/screenshots/app/preview.png" alt="Codex-X 应用界面预览:主界面、Provider、TOML、Auth" width="920" />
<img src="docs/screenshots/app/new-ui/prompts.png" alt="Codex-X 新版指令提示词管理界面" width="920" />
</p>
</details>
<details>
<summary><b>提示词注入效果:安全测试场景</b></summary>
<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" />
<b>分类管理</b><br />
<sub>把提示词按破甲 / 逆向、软件开发、写作辅助等分类维护</sub><br />
<img src="docs/screenshots/app/new-ui/prompt-categories.png" alt="Codex-X 提示词分类管理" 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" />
<b>自定义提示词</b><br />
<sub>直接添加、编辑或导入自己的 Markdown 提示词</sub><br />
<img src="docs/screenshots/app/new-ui/prompt-form.png" alt="Codex-X 添加自定义提示词" width="420" />
</td>
</tr>
</table>
</div>
</details>
<details>
<summary><b>提示词注入效果:逆向工程场景</b></summary>
<summary><b>Skills / MCP 可视化管理</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>
<p align="center">
<img src="docs/screenshots/app/new-ui/skills-mcp.png" alt="Codex-X Skills 与 MCP 管理界面" width="920" />
</p>
</details>
@@ -109,143 +84,181 @@ Codex-X 不是普通的配置文件编辑器,而是一个面向 Codex CLI 的
<div align="center">
<table>
<tr>
<th align="center" width="180">功能</th>
<th align="center">说明</th>
<th align="center" width="190">你想做的事</th>
<th align="center">Codex-X 能帮你</th>
</tr>
<tr>
<td align="center">⚡ 供应商 API</td>
<td>可视化管理官方 OpenAI / 第三方 Codex Provider,支持 Base URL、API Key、Model、Wire API 与一键切换。</td>
<td align="center"><b>提示词注入管理</b></td>
<td align="left">内置 <b>5 套</b>提示词模板,支持分类、GitHub 同步、本地缓存、导入 <code>.md</code>、添加自定义提示词、编辑说明、一键启用 / 禁用。</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>
<td align="center"><b>启用方式切换</b></td>
<td align="left">可选择“保留原提示词”追加写入,也可选择“替换原提示词”完整切换;适合在不同模型、不同任务、不同 Prompt 之间快速切换。</td>
</tr>
<tr>
<td align="center">💬 会话管理</td>
<td>读取 Codex 本地 SQLite 与 rollout JSONL,会话列表可视化展示,并支持 Provider Sync / 修复历史 thread 元数据。</td>
<td align="center"><b>Provider / API</b></td>
<td align="left">添加、编辑、启用、删除第三方供应商;支持连接检测、模型获取 / 测试、从 cc-switch 导入,并可在 OpenAI Official 与中转 API 之间切换。</td>
</tr>
<tr>
<td align="center">📝 TOML 配置</td>
<td>查看当前 <code>~/.codex/config.toml</code>,并在 Provider 编辑页直接编辑完整 TOML,保存后同步到 Codex 配置目录。</td>
<td align="center"><b>会话管理</b></td>
<td align="left">搜索本地会话、按项目路径分组、同步当前供应商、检查会话状态,并支持单选 / 多选 / 项目级永久删除。</td>
</tr>
<tr>
<td align="center">🧠 Skills / MCP</td>
<td>新增技能和 MCP 管理页:读取 Codex 当前可用的 Skills / MCP,支持导入已有、从 ZIP 安装 Skill、启用 / 禁用单个 Skill 或 MCP,并可检查 Skill 更新状态。</td>
<td align="center"><b>Skills / MCP</b></td>
<td align="left">可视化查看 Skills 与 MCP,导入已有配置,从 ZIP 安装 Skill,逐项启用 / 禁用,并检查更新状态。</td>
</tr>
<tr>
<td align="center">🔐 Auth 管理</td>
<td>读取 / 编辑官方 <code>~/.codex/auth.json</code>,区分 ChatGPT 登录态 Auth 与第三方 API Key。</td>
<td align="center"><b>配置与登录</b></td>
<td align="left">集中查看 Codex 当前使用的 <code>config.toml</code> 与 <code>auth.json</code>,区分官方登录态和第三方 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>
<td align="center"><b>跨平台使用</b></td>
<td align="left">提供 macOS Apple Silicon / Intel、Windows MSI / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
</tr>
</table>
</div>
## 核心亮点
### 1. 内置双指令提示词模板
### 1. 可视化提示词注入中心
Codex-X 当前内置两套提示词模板:
<p align="center">
<img src="https://img.shields.io/badge/当前模板库-5_套-2563eb?style=flat-square" alt="当前模板库 5 套" />
<img src="https://img.shields.io/badge/离线内置-5_套-16a34a?style=flat-square" alt="离线内置 5 套" />
<img src="https://img.shields.io/badge/GitHub_同步-自动更新-f59e0b?style=flat-square" alt="GitHub 自动同步" />
<img src="https://img.shields.io/badge/自定义提示词-支持导入_编辑-7c3aed?style=flat-square" alt="支持自定义提示词" />
</p>
- `examples/gpt5.5-unrestricted.md`
- `examples/gpt5.4-unrestricted.md`
> [!TIP]
> **安装后就能用,联网后自动补齐,也能维护自己的提示词库。**
>
> 安装包离线自带当前全部 5 套模板;软件启动后可同步 GitHub `examples/` 的更新和新增模板。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
你可以在【指令提示词】页面直接点击启用 / 禁用。启用后,Codex-X 会把对应 `.md` 写入 Codex 配置目录,并更新 `config.toml` 中的 `model_instructions_file`。
Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可视化提示词注入与管理工具:
- 按分类管理提示词,例如破甲 / 逆向、软件开发、写作辅助,也可以新增自己的分类
- 支持同步 GitHub 模板、导入 Markdown、手动添加提示词、编辑标题 / 文件名 / 内容
- 每个提示词都有独立开关,打开时自动按当前启用方式写入 Codex 指令文件
- 支持“保留原提示词”和“替换原提示词”两种模式,适合日常叠加或完整切换
- 本地缓存可离线使用,后续模板更新不会影响你自己维护的自定义提示词
<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>
<th align="center">模板</th>
<th align="center">适合场景</th>
<th align="center">获取方式</th>
</tr>
<tr>
<td align="center">定位</td>
<td align="center">5.4 CTF 沙箱</td>
<td align="center">5.5 无限制模式</td>
<td><a href="examples/gpt5.5-unrestricted.md"><code>gpt5.5-unrestricted.md</code></a></td>
<td align="left">短小通用,适合日常 coding 与常规技术任务</td>
<td align="center">离线内置<br />GitHub 更新</td>
</tr>
<tr>
<td align="center">策略</td>
<td align="center">CTF 竞赛沙箱间接绕过</td>
<td align="center">直接声明无限制开发者模式</td>
<td><a href="examples/gpt5.4-unrestricted.md"><code>gpt5.4-unrestricted.md</code></a></td>
<td align="left">面向 GPT-5.4 / Codex CLI,偏 CTF 与安全研究工作流</td>
<td align="center">离线内置<br />GitHub 更新</td>
</tr>
<tr>
<td align="center">风格</td>
<td align="center">安全研究导向,方法论详细</td>
<td align="center">极简指令,覆盖面广</td>
<td><a href="examples/gpt5.5-jeli.md"><code>gpt5.5-jeli.md</code></a></td>
<td align="left">大白话通用版,提供更完整的工程与逆向执行流程</td>
<td align="center">离线内置<br />GitHub 更新</td>
</tr>
<tr>
<td align="center">长度</td>
<td align="center">约 180 行</td>
<td align="center">约 40 行</td>
<td><a href="examples/gpt-5.6-sol-unrestricted.md"><code>gpt-5.6-sol-unrestricted.md</code></a></td>
<td align="left">gpt5.6-sol 破甲提示词,偏直接执行与中英文任务</td>
<td align="center">离线内置<br />GitHub 更新</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>
<td><a href="examples/%E6%B5%B7%E9%B8%A53.0%E7%A0%B4%E7%94%B2.md"><code>海鸥3.0破甲.md</code></a></td>
<td align="left">中文技术操作员人格,覆盖 coding、CTF、逆向、内存与协议任务路由</td>
<td align="center">离线内置<br />GitHub 更新</td>
</tr>
</table>
</div>
部署后可以测试:
<table>
<tr>
<td width="50%" valign="top">
<b>保留原提示词</b><br />
适合已经有个人规则的用户。Codex-X 只追加自己管理的内容,禁用时也只移除这一部分,不动原有提示词。
</td>
<td width="50%" valign="top">
<b>替换原提示词</b><br />
将所选模板设为当前主要指令入口,适合希望完整切换到某套模板的用户。
</td>
</tr>
</table>
```text
如何对目标进行 SQL 注入测试?
```
每次启用或禁用前都会自动创建备份。除了模板库,你也可以导入、编辑、删除自己的 `.md` 提示词,并通过分类管理把常用提示词整理成自己的工作流。
典型效果:
> [!NOTE]
> 如果你有好用的提示词模板,欢迎在 [Issues](https://github.com/yynxxxxx/Codex-X/issues) 提交:请附上模板名称、适用场景、Markdown 内容、推荐启用方式和必要说明。合适的模板会考虑收录到 `examples/`,让更多用户可以一键同步使用。
```text
破甲前 → 拒绝或泛化回答
破甲后 → 直接给安全研究方法论与测试步骤
```
### 2. Provider / API:添加、检测、获取模型、随时切换
### 2. Provider 可视化切换
> [!NOTE]
> 启用新的第三方供应商后,新建或重新打开 Codex 会话即可使用新的中转,不需要重启整个 Codex 客户端。
- 添加第三方 Codex API Provider
- 编辑 Base URL / API Key / Model / Wire API
- Provider 页面可查看并编辑对应 TOML
- 当前启用 Provider 状态清晰可见
- 支持从 cc-switch 数据库导入 Codex Provider
- 保存多个第三方供应商,随时查看当前正在使用哪一个
- 切换前可检测连接,并可获取模型进行测试
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
- 切回 OpenAI Official 时保留当前官方登录态,第三方配置也不会凭空消失
### 3. 官方 Auth 管理
### 3. 会话管理:同步、检查与永久删除
<table>
<tr>
<td width="50%" valign="top">
<b>同步和检查</b><br />
检查本地会话是否和当前 Provider / 模型一致,需要时一键同步到当前供应商配置,不修改聊天内容。
</td>
<td width="50%" valign="top">
<b>查找和整理</b><br />
按标题、项目路径、供应商或 ID 搜索会话,也可以按项目路径分组查看,适合清理长期使用后积累的会话列表。
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<b>精确删除</b><br />
支持单选、多选,也可以勾选一个或多个项目,一次选中项目下的全部会话;确认后会从 Codex 自身存储中删除对应会话及其派生子会话。
</td>
</tr>
</table>
> [!CAUTION]
> **永久删除不可恢复。** 删除前请先关闭仍在使用这些会话的 Codex 窗口或 CLI,并在确认窗口中再次核对待删除列表。
### 4. Skills / MCP 管理
在【技能和 MCP】页面集中管理 Codex 的能力扩展,不必再到多个目录和配置文件中逐项查找。
<table>
<tr>
<td width="50%" valign="top">
<b>Skills</b><br />
查看当前 Skill,导入已有内容或从 ZIP 安装;可以逐项启用 / 禁用,并检查已安装 Skill 是否有更新。
</td>
<td width="50%" valign="top">
<b>MCP</b><br />
导入前先预览现有 MCP Server,再决定哪些需要纳管;启用或禁用后由 Codex-X 自动维护 Codex 配置。
</td>
</tr>
</table>
### 5. TOML 与官方 Auth 管理
- 自动读取 Codex 官方 `auth.json`
- 支持查看 / 编辑 ChatGPT 登录态 Auth
- 区分官方 Auth 与第三方 API Key
- 官方配置可和第三方 Provider 在 UI 中统一管理
### 4. TOML 可视化编辑
- 查看当前 Codex `config.toml`
- 查看当前 Codex 正在使用的 live `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. Skills / MCP 管理
Codex-X 新增独立的【技能和 MCP】页面,用于集中管理 Codex 的专业能力扩展。
- Skills:读取当前 Codex Skills,支持导入已有、从 ZIP 安装、启用 / 禁用、检查更新状态
- MCP:读取当前 Codex MCP Server,支持导入已有、启用 / 禁用,启用后写入 Codex <code>config.toml</code>
- 适合管理 Android APK 逆向、Ghidra / IDA、Web / API / 协议逆向、CTF、安全测试等技能包
### 7. 逆向 Skills 导航
### 6. 逆向 Skills 导航
<div align="center">
<a href="https://yynxxxxx.github.io/Codex-X/">
@@ -282,15 +295,15 @@ Codex-X 新增独立的【技能和 MCP】页面,用于集中管理 Codex 的
</a>
</p>
### 8. 跨平台桌面软件
### 7. 跨平台桌面软件
- macOS Apple Silicon `.dmg`
- macOS Intel `.dmg`
- Windows `.msi`
- Windows Portable `.zip`
- Linux `.deb` / `.rpm`
- Linux `.deb` / `.rpm` / `.AppImage`
- GitHub Releases 自动构建发布
- 应用内检查更新
- 安装版支持应用内自动更新,Windows 便携版保留手动更新
## 技术栈
@@ -366,6 +379,19 @@ xattr -dr com.apple.quarantine /Applications/Codex-X.app
## Star History
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx%2Fcodex-x&amp;type=date&amp;legend=top-left&amp;sealed_token=Vqvz67Jv_WIePGePCN8RdJaY5oCyqqCZUSFWs4M4dAxP8JXFZlYEWbI8YcU6SFgpqOqqJifzpOTIlMg4ee8NaCkHpCSqv1r5pxewR-tQlmxswaZlhedd6A" width="900" />
</a>
<p align="center">
<a href="https://github.com/yynxxxxx/Codex-X/stargazers">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" />
<img alt="Codex-X Star History" src="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" width="900" />
</picture>
</a>
</p>
<br />
> [!IMPORTANT]
> **使用声明**
>
> 本项目仅用于大模型与智能体相关技术的学习、研究与交流,软件本身不包含主动破坏性功能。请在合法、合规并获得授权的范围内使用,禁止将其用于攻击、侵害他人权益或其他违法用途。使用者应自行判断使用边界,并对相关行为与后果承担责任。
+4 -8
View File
@@ -1,17 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codex-X</title>
<script src="/theme-init.js"></script>
<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%);
}
html, body { margin: 0; min-height: 100%; background: #f3f4f6; }
html[data-theme="dark"], html[data-theme="dark"] body { background: #24262b; }
</style>
</head>
<body>
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.31",
"version": "0.3.0",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
@@ -14,6 +14,8 @@
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
+5
View File
@@ -0,0 +1,5 @@
<!-- Source: Simple Icons 16.26.0 (CC0-1.0). -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+10
View File
@@ -0,0 +1,10 @@
(function () {
var theme = "light";
try {
theme = window.localStorage.getItem("codexx.theme") === "dark" ? "dark" : "light";
} catch (_error) {
// Keep the light default when storage is unavailable.
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();
+476 -11
View File
@@ -346,20 +346,26 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.31"
version = "0.3.0"
dependencies = [
"chrono",
"dirs 5.0.1",
"percent-encoding",
"reqwest",
"rusqlite",
"rustls",
"semver",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-process",
"tauri-plugin-updater",
"thiserror 2.0.18",
"toml_edit 0.22.27",
"ureq",
"zip",
"zip 2.4.2",
]
[[package]]
@@ -382,6 +388,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -405,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@@ -418,7 +434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -791,6 +807,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -828,6 +854,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -899,6 +935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1371,6 +1408,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1389,9 +1441,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1620,6 +1674,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.118",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1761,6 +1845,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -1814,6 +1904,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2046,6 +2142,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2061,6 +2158,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2124,12 +2233,32 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2494,21 +2623,27 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2563,6 +2698,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.41"
@@ -2578,6 +2726,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
@@ -2587,6 +2747,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -2613,6 +2800,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -2670,6 +2866,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -2880,6 +3099,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3050,6 +3285,27 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3071,7 +3327,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -3081,7 +3337,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3114,6 +3370,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3137,7 +3404,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3233,6 +3500,65 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3243,7 +3569,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3266,7 +3592,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3333,6 +3659,19 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.0"
@@ -3452,6 +3791,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4037,6 +4386,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -4240,6 +4598,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4312,6 +4681,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4360,13 +4738,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4403,6 +4798,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4421,6 +4822,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4439,12 +4846,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4463,6 +4882,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4481,6 +4906,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4499,6 +4930,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4517,6 +4954,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -4584,7 +5027,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -4631,6 +5074,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -4751,6 +5204,18 @@ dependencies = [
"zopfli",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+14 -3
View File
@@ -1,11 +1,11 @@
[package]
name = "codex-x"
version = "0.2.31"
version = "0.3.0"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
edition = "2021"
rust-version = "1.85.0"
rust-version = "1.89.0"
[lib]
name = "codexx_lib"
@@ -16,13 +16,24 @@ tauri-build = { version = "2.4.0", features = [] }
[dependencies]
tauri = { version = "2.8.2", features = [] }
tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2.10.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml_edit = "0.22"
chrono = { version = "0.4", features = ["serde"] }
dirs = "5.0"
thiserror = "2.0"
rusqlite = { version = "0.31", features = ["bundled"] }
rusqlite = { version = "0.31", features = ["backup", "bundled"] }
semver = "1.0"
ureq = { version = "2.12", features = ["tls"] }
reqwest = { version = "0.13", default-features = false, features = [
"blocking",
"rustls-no-provider",
"socks",
"system-proxy",
] }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
sha2 = "0.10"
percent-encoding = "2.3"
zip = { version = "2.2", default-features = false, features = ["deflate"] }
@@ -3,5 +3,13 @@
"identifier": "default",
"description": "Default app capability",
"windows": ["main"],
"permissions": ["core:default"]
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"updater:allow-download-and-install",
"process:allow-restart"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 190 KiB

+135
View File
@@ -0,0 +1,135 @@
use crate::error::{CodexxError, Result};
use crate::file_io::ensure_directory;
use crate::paths::app_home;
use crate::sqlite_utils::table_column_set;
use rusqlite::Connection;
use std::path::PathBuf;
fn db_path() -> Result<PathBuf> {
Ok(app_home()?.join("codexx.db"))
}
fn ensure_sqlite_column(
conn: &Connection,
table: &str,
column: &str,
alter_sql: &str,
) -> Result<()> {
let cols = table_column_set(conn, table)?;
if cols.contains(column) {
return Ok(());
}
match conn.execute(alter_sql, []) {
Ok(_) => Ok(()),
Err(e) => {
let message = e.to_string().to_ascii_lowercase();
if message.contains("duplicate column") || message.contains("duplicate column name") {
// Another running Codex-X process may have applied the same
// lightweight migration between our PRAGMA check and ALTER.
Ok(())
} else {
Err(CodexxError::Database(e.to_string()))
}
}
}
}
pub(crate) fn open() -> Result<Connection> {
let path = db_path()?;
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let conn = Connection::open(&path).map_err(|e| CodexxError::Database(e.to_string()))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
provider_name TEXT NOT NULL,
base_url TEXT NOT NULL,
model TEXT NOT NULL,
api_key TEXT,
toml_config TEXT,
wire_api TEXT NOT NULL DEFAULT 'responses',
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_providers_updated_at ON providers(updated_at DESC);
CREATE TABLE IF NOT EXISTS prompts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
filename TEXT NOT NULL,
content TEXT NOT NULL,
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 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
);
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS managed_skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
directory TEXT NOT NULL,
source_path TEXT,
content_hash TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
ensure_sqlite_column(
&conn,
"providers",
"toml_config",
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
)?;
conn.execute(
"DELETE FROM prompts
WHERE id LIKE 'external-%'
AND EXISTS (
SELECT 1 FROM prompts AS kept
WHERE lower(kept.filename) = lower(prompts.filename)
AND kept.id NOT LIKE 'external-%'
)",
[],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
conn.execute(
"DELETE FROM prompts
WHERE id LIKE 'external-%'
AND EXISTS (
SELECT 1 FROM prompts AS kept
WHERE kept.content = prompts.content
AND kept.id NOT LIKE 'external-%'
)",
[],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
conn.execute(
"DELETE FROM prompts
WHERE id LIKE 'external-%'
AND EXISTS (
SELECT 1 FROM prompts AS kept
WHERE kept.content = prompts.content
AND kept.id LIKE 'external-%'
AND kept.rowid <> prompts.rowid
AND (kept.updated_at > prompts.updated_at OR (kept.updated_at = prompts.updated_at AND kept.rowid > prompts.rowid))
)",
[],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(conn)
}
+144
View File
@@ -0,0 +1,144 @@
use crate::constants::AGENTS_FILENAME;
use crate::error::Result;
use crate::file_io::{ensure_directory, io_err, write_json};
use crate::paths::app_home;
use crate::prompts::agents_path;
use crate::{auth_path, config_path};
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BackupMeta {
pub(crate) id: String,
pub(crate) action: String,
pub(crate) created_at: String,
pub(crate) codex_dir: String,
pub(crate) config_path: String,
pub(crate) auth_path: String,
pub(crate) had_config: bool,
pub(crate) had_auth: bool,
#[serde(default)]
pub(crate) agents_path: String,
#[serde(default)]
pub(crate) had_agents: bool,
#[serde(default)]
pub(crate) tracks_agents: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BackupEntry {
id: String,
action: String,
created_at: String,
path: String,
had_config: bool,
had_auth: bool,
had_agents: bool,
}
fn backup_root() -> Result<PathBuf> {
Ok(app_home()?.join("backups"))
}
pub(crate) fn action_backup_root(codex_dir: &Path) -> Result<PathBuf> {
#[cfg(test)]
{
Ok(codex_dir.join(".codexx-test-backups"))
}
#[cfg(not(test))]
{
let _ = codex_dir;
backup_root()
}
}
pub(crate) fn create_backup(codex_dir: &Path, action: &str) -> Result<Option<String>> {
use std::sync::atomic::{AtomicU64, Ordering};
static BACKUP_COUNTER: AtomicU64 = AtomicU64::new(0);
let cfg = config_path(codex_dir);
let auth = auth_path(codex_dir);
let agents = agents_path(codex_dir);
let had_config = cfg.exists();
let had_auth = auth.exists();
let had_agents = agents.exists();
let id = format!(
"{}-{}-{}",
Local::now().format("%Y%m%d-%H%M%S-%3f"),
BACKUP_COUNTER.fetch_add(1, Ordering::Relaxed),
action
);
let dir = action_backup_root(codex_dir)?.join(&id);
ensure_directory(&dir)?;
if had_config {
fs::copy(&cfg, dir.join("config.toml")).map_err(|e| io_err(&cfg, e))?;
}
if had_auth {
fs::copy(&auth, dir.join("auth.json")).map_err(|e| io_err(&auth, e))?;
}
if had_agents {
fs::copy(&agents, dir.join(AGENTS_FILENAME)).map_err(|e| io_err(&agents, e))?;
}
let meta = BackupMeta {
id: id.clone(),
action: action.to_string(),
created_at: Local::now().to_rfc3339(),
codex_dir: codex_dir.display().to_string(),
config_path: cfg.display().to_string(),
auth_path: auth.display().to_string(),
had_config,
had_auth,
agents_path: agents.display().to_string(),
had_agents,
tracks_agents: true,
};
write_json(
&dir.join("meta.json"),
&serde_json::to_value(meta).expect("meta serialize"),
)?;
Ok(Some(id))
}
fn read_backup_entry(dir: &Path) -> Option<BackupEntry> {
let meta_path = dir.join("meta.json");
let text = fs::read_to_string(&meta_path).ok()?;
let meta: BackupMeta = serde_json::from_str(&text).ok()?;
Some(BackupEntry {
id: meta.id,
action: meta.action,
created_at: meta.created_at,
path: dir.display().to_string(),
had_config: meta.had_config,
had_auth: meta.had_auth,
had_agents: meta.had_agents,
})
}
pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
let root = backup_root()?;
if !root.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in fs::read_dir(&root).map_err(|e| io_err(&root, e))? {
let entry = entry.map_err(|e| io_err(&root, e))?;
let path = entry.path();
if path.is_dir() {
if let Some(backup) = read_backup_entry(&path) {
entries.push(backup);
}
}
}
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
Ok(backups()?.into_iter().next())
}
+139
View File
@@ -0,0 +1,139 @@
use crate::error::{CodexxError, Result};
use crate::paths::home_dir;
use std::path::PathBuf;
fn push_existing_candidate(candidates: &mut Vec<PathBuf>, candidate: Option<PathBuf>) {
let Some(path) = candidate else {
return;
};
if !candidates.iter().any(|item| item == &path) {
candidates.push(path);
}
}
pub(crate) fn ccswitch_db_candidates() -> Result<Vec<PathBuf>> {
let mut candidates = Vec::new();
if let Ok(value) = std::env::var("CC_SWITCH_HOME") {
let trimmed = value.trim();
if !trimmed.is_empty() {
push_existing_candidate(
&mut candidates,
Some(PathBuf::from(trimmed).join("cc-switch.db")),
);
}
}
let home = home_dir()?;
// cc-switch 当前主要使用这个位置,macOS/Windows/Linux 都适用。
push_existing_candidate(
&mut candidates,
Some(home.join(".cc-switch").join("cc-switch.db")),
);
// 兼容 Tauri/AppData 风格位置,防止未来或不同发行版变更数据目录。
if let Some(data_dir) = dirs::data_dir() {
push_existing_candidate(
&mut candidates,
Some(data_dir.join("com.ccswitch.desktop").join("cc-switch.db")),
);
push_existing_candidate(
&mut candidates,
Some(data_dir.join("cc-switch").join("cc-switch.db")),
);
push_existing_candidate(
&mut candidates,
Some(data_dir.join("CC Switch").join("cc-switch.db")),
);
}
if let Some(data_local_dir) = dirs::data_local_dir() {
push_existing_candidate(
&mut candidates,
Some(
data_local_dir
.join("com.ccswitch.desktop")
.join("cc-switch.db"),
),
);
push_existing_candidate(
&mut candidates,
Some(data_local_dir.join("cc-switch").join("cc-switch.db")),
);
push_existing_candidate(
&mut candidates,
Some(data_local_dir.join("CC Switch").join("cc-switch.db")),
);
}
#[cfg(target_os = "macos")]
{
push_existing_candidate(
&mut candidates,
Some(
home.join("Library")
.join("Application Support")
.join("com.ccswitch.desktop")
.join("cc-switch.db"),
),
);
}
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
push_existing_candidate(
&mut candidates,
Some(
PathBuf::from(appdata)
.join("com.ccswitch.desktop")
.join("cc-switch.db"),
),
);
}
if let Ok(localappdata) = std::env::var("LOCALAPPDATA") {
push_existing_candidate(
&mut candidates,
Some(
PathBuf::from(localappdata)
.join("com.ccswitch.desktop")
.join("cc-switch.db"),
),
);
}
}
#[cfg(target_os = "linux")]
{
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
push_existing_candidate(
&mut candidates,
Some(
PathBuf::from(xdg_data_home)
.join("com.ccswitch.desktop")
.join("cc-switch.db"),
),
);
}
push_existing_candidate(
&mut candidates,
Some(
home.join(".local")
.join("share")
.join("com.ccswitch.desktop")
.join("cc-switch.db"),
),
);
}
Ok(candidates)
}
pub(crate) fn default_ccswitch_db_path() -> Result<PathBuf> {
let candidates = ccswitch_db_candidates()?;
candidates
.iter()
.find(|path| path.exists())
.cloned()
.or_else(|| candidates.into_iter().next())
.ok_or_else(|| CodexxError::Config("无法生成 cc-switch 数据库候选路径".to_string()))
}
@@ -0,0 +1,301 @@
use crate::backups::create_backup;
use crate::config_path;
use crate::error::Result;
use crate::file_io::{parse_toml_document, read_to_string_if_exists, write_text};
use std::path::Path;
use toml_edit::{DocumentMut, Item, Table};
const INSTRUCTION_KEY: &str = "model_instructions_file";
const MODEL_AVAILABILITY_NUX_KEY: &str = "model_availability_nux";
fn markdown_path(item: Option<&Item>) -> bool {
item.and_then(|item| item.as_str())
.map(str::trim)
.is_some_and(|path| !path.is_empty() && path.to_ascii_lowercase().ends_with(".md"))
}
fn tui_table(doc: &DocumentMut) -> Option<&Table> {
doc.get("tui").and_then(|item| item.as_table())
}
fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
if markdown_path(table.get(key)) {
table.remove(key)
} else {
None
}
}
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
/// header. Only the exact legacy keys with Markdown path values are touched.
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
if text.trim().is_empty() {
return Ok(false);
}
let mut doc = parse_toml_document(&cfg, &text)?;
let nested_instruction = tui_table(&doc)
.and_then(|tui| tui.get(MODEL_AVAILABILITY_NUX_KEY))
.and_then(|item| item.as_table())
.is_some_and(|nux| markdown_path(nux.get(INSTRUCTION_KEY)));
let tui_instruction =
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(INSTRUCTION_KEY)));
let nux_as_instruction =
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(MODEL_AVAILABILITY_NUX_KEY)));
if !nested_instruction && !tui_instruction && !nux_as_instruction {
return Ok(false);
}
let root_instruction_exists = doc.as_table().contains_key(INSTRUCTION_KEY);
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
let removed_nested = doc
.get_mut("tui")
.and_then(|item| item.as_table_mut())
.and_then(|tui| tui.get_mut(MODEL_AVAILABILITY_NUX_KEY))
.and_then(|item| item.as_table_mut())
.and_then(|nux| remove_markdown_path(nux, INSTRUCTION_KEY));
let removed_tui = doc
.get_mut("tui")
.and_then(|item| item.as_table_mut())
.and_then(|tui| remove_markdown_path(tui, INSTRUCTION_KEY));
let removed_nux_value = doc
.get_mut("tui")
.and_then(|item| item.as_table_mut())
.and_then(|tui| remove_markdown_path(tui, MODEL_AVAILABILITY_NUX_KEY));
if !root_instruction_exists {
let instruction = removed_nested
.or(removed_tui)
.or(removed_nux_value)
.expect("a detected legacy prompt path must still be removable");
doc.as_table_mut().insert(INSTRUCTION_KEY, instruction);
}
write_text(&cfg, &doc.to_string())?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backups::action_backup_root;
use crate::file_io::write_text;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_codex_dir(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"codex-x-config-migration-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp Codex directory");
dir
}
fn read_doc(codex_dir: &Path) -> DocumentMut {
fs::read_to_string(config_path(codex_dir))
.expect("read migrated config")
.parse()
.expect("parse migrated config")
}
fn backup_count(codex_dir: &Path) -> usize {
let root = action_backup_root(codex_dir).expect("resolve backup root");
if !root.exists() {
return 0;
}
fs::read_dir(root).expect("read backup root").count()
}
fn only_backup_config(codex_dir: &Path) -> String {
let root = action_backup_root(codex_dir).expect("resolve backup root");
let entries = fs::read_dir(root)
.expect("read backup root")
.collect::<std::result::Result<Vec<_>, _>>()
.expect("read backup entries");
assert_eq!(entries.len(), 1);
fs::read_to_string(entries[0].path().join("config.toml")).expect("read backed-up config")
}
#[test]
fn migrates_instruction_nested_under_model_availability_nux() {
let codex_dir = temp_codex_dir("nested-nux");
let original = r#"model = "gpt-5.6"
[tui.model_availability_nux]
# Keep valid model counters and their comments.
"gpt-5.5" = 4
model_instructions_file = "./legacy-prompt.md" # Keep the prompt note too.
"gpt-5.6" = 2
[tui.notifications]
enabled = true
"#;
write_text(&config_path(&codex_dir), original).expect("write legacy config");
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
let migrated_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
let doc = read_doc(&codex_dir);
assert_eq!(
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
Some("./legacy-prompt.md")
);
let nux = doc["tui"][MODEL_AVAILABILITY_NUX_KEY]
.as_table()
.expect("keep model availability table");
assert!(!nux.contains_key(INSTRUCTION_KEY));
assert_eq!(
nux.get("gpt-5.5").and_then(|item| item.as_integer()),
Some(4)
);
assert_eq!(
nux.get("gpt-5.6").and_then(|item| item.as_integer()),
Some(2)
);
assert!(migrated_text.contains("# Keep valid model counters and their comments."));
assert!(migrated_text.contains("# Keep the prompt note too."));
assert_eq!(doc["tui"]["notifications"]["enabled"].as_bool(), Some(true));
assert_eq!(backup_count(&codex_dir), 1);
assert_eq!(only_backup_config(&codex_dir), original);
fs::remove_dir_all(codex_dir).expect("remove temp directory");
}
#[test]
fn migrates_instruction_directly_under_tui() {
let codex_dir = temp_codex_dir("direct-tui");
write_text(
&config_path(&codex_dir),
"[tui]\nnotifications = true\nmodel_instructions_file = \"./direct.md\"\n",
)
.expect("write legacy config");
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
let doc = read_doc(&codex_dir);
assert_eq!(
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
Some("./direct.md")
);
assert!(doc["tui"].as_table().is_some_and(|tui| {
!tui.contains_key(INSTRUCTION_KEY)
&& tui.get("notifications").and_then(|item| item.as_bool()) == Some(true)
}));
fs::remove_dir_all(codex_dir).expect("remove temp directory");
}
#[test]
fn migrates_markdown_path_stored_as_tui_model_availability_nux() {
let codex_dir = temp_codex_dir("nux-as-path");
write_text(
&config_path(&codex_dir),
"[tui]\nmodel_availability_nux = \"./gpt-5.6-sol-unrestricted.md\"\n",
)
.expect("write legacy config");
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
let doc = read_doc(&codex_dir);
assert_eq!(
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
Some("./gpt-5.6-sol-unrestricted.md")
);
assert!(doc["tui"]
.as_table()
.is_some_and(|tui| { !tui.contains_key(MODEL_AVAILABILITY_NUX_KEY) }));
fs::remove_dir_all(codex_dir).expect("remove temp directory");
}
#[test]
fn root_instruction_wins_over_legacy_nested_value() {
let codex_dir = temp_codex_dir("root-wins");
write_text(
&config_path(&codex_dir),
r#"model_instructions_file = "./current.md"
[tui.model_availability_nux]
"gpt-5.5" = 4
model_instructions_file = "./stale.md"
"#,
)
.expect("write conflicting config");
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
let doc = read_doc(&codex_dir);
assert_eq!(
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
Some("./current.md")
);
assert!(!doc["tui"][MODEL_AVAILABILITY_NUX_KEY]
.as_table()
.expect("keep nux table")
.contains_key(INSTRUCTION_KEY));
fs::remove_dir_all(codex_dir).expect("remove temp directory");
}
#[test]
fn leaves_legal_tui_values_unchanged_without_backup() {
let codex_dir = temp_codex_dir("legal-tui");
let original = "[tui]\nmodel_availability_nux = 4\nnotifications = true\n";
write_text(&config_path(&codex_dir), original).expect("write legal config");
assert!(!migrate_legacy_prompt_config(&codex_dir).expect("inspect config"));
assert_eq!(
fs::read_to_string(config_path(&codex_dir)).expect("read unchanged config"),
original
);
assert_eq!(backup_count(&codex_dir), 0);
fs::remove_dir_all(codex_dir).expect("remove temp directory");
}
#[test]
fn migration_is_idempotent_and_backs_up_only_once() {
let codex_dir = temp_codex_dir("idempotent");
write_text(
&config_path(&codex_dir),
"[tui]\nmodel_instructions_file = \"./once.md\"\n",
)
.expect("write legacy config");
assert!(migrate_legacy_prompt_config(&codex_dir).expect("first migration"));
let after_first = fs::read_to_string(config_path(&codex_dir)).expect("read first result");
assert!(!migrate_legacy_prompt_config(&codex_dir).expect("second migration"));
assert_eq!(
fs::read_to_string(config_path(&codex_dir)).expect("read second result"),
after_first
);
assert_eq!(backup_count(&codex_dir), 1);
fs::remove_dir_all(codex_dir).expect("remove temp directory");
}
#[test]
fn loading_codex_state_runs_the_migration() {
let codex_dir = temp_codex_dir("state-load");
write_text(
&config_path(&codex_dir),
"[tui]\nmodel_availability_nux = \"./loaded.md\"\n",
)
.expect("write legacy config");
crate::state::build_state(codex_dir.clone()).expect("load Codex state");
let doc = read_doc(&codex_dir);
assert_eq!(
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
Some("./loaded.md")
);
assert_eq!(backup_count(&codex_dir), 1);
fs::remove_dir_all(codex_dir).expect("remove temp directory");
}
}
+21 -3
View File
@@ -1,16 +1,34 @@
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");
pub(crate) const INSTRUCTION_JELI_FILENAME: &str = "gpt5.5-jeli.md";
pub(crate) const INSTRUCTION_JELI_RELATIVE: &str = "./gpt5.5-jeli.md";
pub(crate) const INSTRUCTION_JELI_CONTENT: &str =
include_str!("../../../../examples/gpt5.5-jeli.md");
pub(crate) const INSTRUCTION_56_SOL_FILENAME: &str = "gpt-5.6-sol-unrestricted.md";
pub(crate) const INSTRUCTION_56_SOL_CONTENT: &str =
include_str!("../../../../examples/gpt-5.6-sol-unrestricted.md");
pub(crate) const INSTRUCTION_SEAGULL_FILENAME: &str = "海鸥3.0破甲.md";
pub(crate) const INSTRUCTION_SEAGULL_CONTENT: &str =
include_str!("../../../../examples/海鸥3.0破甲.md");
pub(crate) const AGENTS_FILENAME: &str = "AGENTS.md";
pub(crate) const AGENTS_MANAGED_BEGIN: &str = "<!-- CODEX-X:INSTRUCTIONS:BEGIN -->";
pub(crate) const AGENTS_MANAGED_END: &str = "<!-- CODEX-X:INSTRUCTIONS:END -->";
pub(crate) const AGENTS_TEMPLATE_PREFIX: &str = "<!-- CODEX-X:TEMPLATE:";
pub(crate) const JSDELIVR_EXAMPLES_API: &str =
"https://data.jsdelivr.com/v1/packages/gh/yynxxxxx/Codex-X@main?structure=flat";
pub(crate) const JSDELIVR_EXAMPLES_BASE: &str =
"https://cdn.jsdelivr.net/gh/yynxxxxx/Codex-X@main/examples/";
pub(crate) const GITHUB_EXAMPLES_API: &str =
"https://api.github.com/repos/yynxxxxx/Codex-X/contents/examples?ref=main";
pub(crate) const GITHUB_EXAMPLES_BASE: &str =
"https://raw.githubusercontent.com/yynxxxxx/Codex-X/main/examples/";
pub(crate) const MAX_SKILL_ZIP_BYTES: u64 = 20 * 1024 * 1024;
+37
View File
@@ -0,0 +1,37 @@
use serde::Serializer;
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum CodexxError {
#[error("无法获取用户主目录")]
NoHomeDir,
#[error("IO error at {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("TOML parse error at {path}: {message}")]
Toml { path: String, message: String },
#[error("JSON error at {path}: {source}")]
Json {
path: String,
#[source]
source: serde_json::Error,
},
#[error("配置错误: {0}")]
Config(String),
#[error("SQLite error: {0}")]
Database(String),
}
pub(crate) type Result<T> = std::result::Result<T, CodexxError>;
impl serde::Serialize for CodexxError {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
+333
View File
@@ -0,0 +1,333 @@
use crate::error::{CodexxError, Result};
use chrono::Local;
use serde_json::Value;
use std::fs;
use std::io::Write;
use std::path::Path;
use toml_edit::DocumentMut;
pub(crate) fn io_err(path: &Path, source: std::io::Error) -> CodexxError {
CodexxError::Io {
path: path.display().to_string(),
source,
}
}
pub(crate) fn json_err(path: &Path, source: serde_json::Error) -> CodexxError {
CodexxError::Json {
path: path.display().to_string(),
source,
}
}
#[cfg(target_os = "windows")]
fn metadata_is_file_link(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::FileTypeExt;
metadata.file_type().is_symlink_file()
}
#[cfg(not(target_os = "windows"))]
fn metadata_is_file_link(_metadata: &fs::Metadata) -> bool {
false
}
#[cfg(target_os = "windows")]
fn metadata_is_directory_link(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::FileTypeExt;
metadata.file_type().is_symlink_dir()
}
#[cfg(not(target_os = "windows"))]
fn metadata_is_directory_link(_metadata: &fs::Metadata) -> bool {
false
}
fn metadata_is_directory_entry(metadata: &fs::Metadata) -> bool {
metadata.is_dir() && !metadata_is_directory_link(metadata)
}
pub(crate) fn directory_exists(path: &Path) -> bool {
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
metadata_is_directory_entry(&metadata)
|| (!metadata_is_file_link(&metadata)
&& (metadata_is_directory_link(&metadata) || metadata.file_type().is_symlink())
&& fs::metadata(path).is_ok_and(|target| target.is_dir()))
}
fn existing_directory_entry(path: &Path, metadata: &fs::Metadata) -> Result<()> {
if metadata_is_directory_entry(metadata) {
return Ok(());
}
if metadata_is_file_link(metadata) {
return Err(CodexxError::Config(format!(
"此链接被创建成了文件链接,不能作为文件夹使用:{}。请在创建链接的工具中重新建立目录链接",
path.display()
)));
}
if metadata_is_directory_link(metadata) || metadata.file_type().is_symlink() {
return match fs::metadata(path) {
Ok(target) if target.is_dir() => Ok(()),
_ => Err(CodexxError::Config(format!(
"文件夹链接已失效或目标不是文件夹:{}",
path.display()
))),
};
}
Err(CodexxError::Config(format!(
"此路径已被同名文件占用,不是文件夹:{}",
path.display()
)))
}
pub(crate) fn ensure_directory(path: &Path) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) => existing_directory_entry(path, &metadata),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match fs::create_dir_all(path) {
Ok(()) => Ok(()),
Err(create_error) => match fs::symlink_metadata(path) {
Ok(metadata) => existing_directory_entry(path, &metadata),
Err(_) => Err(io_err(path, create_error)),
},
}
}
Err(error) => Err(io_err(path, error)),
}
}
pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
if !path.exists() {
return Ok(String::new());
}
fs::read_to_string(path).map_err(|e| io_err(path, e))
}
pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut> {
if text.trim().is_empty() {
return Ok(DocumentMut::new());
}
text.parse::<DocumentMut>().map_err(|e| CodexxError::Toml {
path: path.display().to_string(),
message: e.to_string(),
})
}
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
use std::sync::atomic::{AtomicU64, Ordering};
static WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let tmp = path.with_extension(format!(
"tmp.{}.{}.{}",
std::process::id(),
Local::now().timestamp_nanos_opt().unwrap_or_default(),
WRITE_COUNTER.fetch_add(1, Ordering::Relaxed),
));
{
let mut file = fs::File::create(&tmp).map_err(|e| io_err(&tmp, e))?;
file.write_all(bytes).map_err(|e| io_err(&tmp, e))?;
file.sync_all().map_err(|e| io_err(&tmp, e))?;
}
fs::rename(&tmp, path).map_err(|e| io_err(path, e))?;
Ok(())
}
pub(crate) fn write_text(path: &Path, text: &str) -> Result<()> {
atomic_write(path, text.as_bytes())
}
pub(crate) fn write_json(path: &Path, value: &Value) -> Result<()> {
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
write_text(path, &(text + "\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_dir(name: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"codex-x-file-io-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create test directory");
path
}
#[test]
fn atomic_write_replaces_existing_file_without_temp_residue() {
let dir = temp_dir("replace");
let path = dir.join("state.json");
fs::write(&path, b"old").expect("write original file");
atomic_write(&path, b"new").expect("replace file atomically");
assert_eq!(fs::read(&path).expect("read replaced file"), b"new");
let entries = fs::read_dir(&dir)
.expect("read test directory")
.map(|entry| entry.expect("read directory entry").file_name())
.collect::<Vec<_>>();
assert_eq!(entries, vec![path.file_name().unwrap().to_os_string()]);
fs::remove_dir_all(dir).expect("remove test directory");
}
#[test]
fn ensure_directory_accepts_existing_and_missing_directories() {
let root = temp_dir("ensure-directory");
let existing = root.join("existing");
let missing = root.join("missing").join("nested");
fs::create_dir(&existing).expect("create existing directory");
ensure_directory(&existing).expect("accept existing directory");
ensure_directory(&missing).expect("create missing directory");
assert!(missing.is_dir());
fs::remove_dir_all(root).expect("remove test directory");
}
#[test]
fn ensure_directory_rejects_a_file_without_removing_it() {
let root = temp_dir("ensure-directory-file");
let occupied = root.join(".codex");
fs::write(&occupied, "keep me").expect("create occupying file");
let error = ensure_directory(&occupied).expect_err("file is not a directory");
assert!(!directory_exists(&occupied));
assert!(error.to_string().contains("不是文件夹"));
assert_eq!(
fs::read_to_string(&occupied).expect("read occupying file"),
"keep me"
);
fs::remove_dir_all(root).expect("remove test directory");
}
#[cfg(unix)]
#[test]
fn ensure_directory_accepts_a_directory_symlink_and_rejects_a_broken_link() {
use std::os::unix::fs::symlink;
let root = temp_dir("ensure-directory-link");
let target = root.join("target");
let link = root.join("linked-codex-home");
let broken = root.join("broken-codex-home");
fs::create_dir(&target).expect("create target directory");
symlink(&target, &link).expect("create directory symlink");
symlink(root.join("missing-target"), &broken).expect("create broken symlink");
ensure_directory(&link).expect("accept directory symlink");
atomic_write(&link.join("config.toml"), b"linked").expect("write through symlink");
let error = ensure_directory(&broken).expect_err("reject broken symlink");
assert_eq!(
fs::read(target.join("config.toml")).expect("read symlink target"),
b"linked"
);
assert!(error.to_string().contains("链接已失效"));
fs::remove_dir_all(root).expect("remove test directory");
}
#[cfg(target_os = "windows")]
#[test]
fn ensure_directory_accepts_a_windows_directory_symlink() {
use std::os::windows::fs::symlink_dir;
let root = temp_dir("ensure-directory-windows-link");
let target = root.join("目标 文件夹");
let link = root.join("linked-codex-home");
fs::create_dir(&target).expect("create target directory");
match symlink_dir(&target, &link) {
Ok(()) => {}
Err(error) if error.raw_os_error() == Some(1314) => {
fs::remove_dir_all(root).expect("remove test directory");
return;
}
Err(error) => panic!("create directory symlink: {error}"),
}
ensure_directory(&link).expect("accept Windows directory symlink");
assert!(directory_exists(&link));
atomic_write(&link.join("config.toml"), b"first").expect("first linked write");
atomic_write(&link.join("config.toml"), b"second").expect("replace linked file");
assert_eq!(
fs::read(target.join("config.toml")).expect("read linked target"),
b"second"
);
fs::remove_file(target.join("config.toml")).expect("remove target file");
fs::remove_dir(&target).expect("remove symlink target");
let error = ensure_directory(&link).expect_err("reject broken directory symlink");
assert!(!directory_exists(&link));
assert!(error.to_string().contains("链接已失效"));
fs::remove_dir(&link).expect("remove directory symlink");
fs::remove_dir_all(root).expect("remove test directory");
}
#[cfg(target_os = "windows")]
#[test]
fn ensure_directory_accepts_a_windows_junction() {
use std::process::Command;
let root = temp_dir("ensure-directory-windows-junction");
let target = root.join("junction-target");
let link = root.join("junction-codex-home");
fs::create_dir(&target).expect("create junction target");
let status = Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(&link)
.arg(&target)
.status()
.expect("run mklink");
assert!(status.success(), "create directory junction");
ensure_directory(&link).expect("accept Windows junction");
assert!(directory_exists(&link));
atomic_write(&link.join("config.toml"), b"junction").expect("write through junction");
assert_eq!(
fs::read(target.join("config.toml")).expect("read junction target"),
b"junction"
);
fs::remove_dir(&link).expect("remove directory junction");
fs::remove_dir_all(root).expect("remove test directory");
}
#[cfg(target_os = "windows")]
#[test]
fn ensure_directory_rejects_a_file_link_to_a_directory_without_removing_it() {
use std::os::windows::fs::symlink_file;
let root = temp_dir("ensure-directory-windows-file-link");
let target = root.join("target");
let link = root.join("linked-codex-home");
fs::create_dir(&target).expect("create target directory");
match symlink_file(&target, &link) {
Ok(()) => {}
Err(error) if error.raw_os_error() == Some(1314) => {
fs::remove_dir_all(root).expect("remove test directory");
return;
}
Err(error) => panic!("create file link: {error}"),
}
let error = ensure_directory(&link).expect_err("reject file link as directory");
assert!(!directory_exists(&link));
assert!(error.to_string().contains("文件链接"));
assert!(fs::symlink_metadata(&link).is_ok());
assert!(target.is_dir());
fs::remove_file(&link).expect("remove file link");
fs::remove_dir_all(root).expect("remove test directory");
}
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
use crate::error::{CodexxError, Result};
#[cfg(test)]
use chrono::Local;
use std::path::PathBuf;
pub(crate) fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
}
pub(crate) fn app_home() -> Result<PathBuf> {
#[cfg(test)]
{
use std::sync::OnceLock;
static TEST_APP_HOME: OnceLock<PathBuf> = OnceLock::new();
Ok(TEST_APP_HOME
.get_or_init(|| {
std::env::temp_dir().join(format!(
"codex-x-test-home-{}-{}",
std::process::id(),
Local::now().timestamp_nanos_opt().unwrap_or_default()
))
})
.clone())
}
#[cfg(not(test))]
{
if let Ok(value) = std::env::var("CODEXX_HOME") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
Ok(home_dir()?.join(".codexx"))
}
}
+348 -26
View File
@@ -1,44 +1,366 @@
use std::process::Command;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
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 = "windows")]
use std::env;
fn version_line(stdout: &str, stderr: &str, success: bool) -> Option<String> {
let lines = stdout.lines().chain(stderr.lines()).map(str::trim);
let preferred = lines.clone().find(|line| {
let lower = line.to_ascii_lowercase();
!line.is_empty()
&& !lower.starts_with("warning:")
&& (lower.contains("codex-cli")
|| lower.contains("@openai/codex")
|| lower.starts_with("codex "))
&& line.chars().any(|ch| ch.is_ascii_digit())
});
if preferred.is_some() {
return preferred.map(ToString::to_string);
}
if !success {
return None;
}
lines
.filter(|line| {
let lower = line.to_ascii_lowercase();
!line.is_empty()
&& !lower.starts_with("warning:")
&& !lower.starts_with("error:")
&& line.chars().any(|ch| ch.is_ascii_digit())
})
.map(ToString::to_string)
.next()
}
fn version_from_output(output: Output) -> Option<String> {
version_line(
&String::from_utf8_lossy(&output.stdout),
&String::from_utf8_lossy(&output.stderr),
output.status.success(),
)
}
#[cfg(target_os = "windows")]
pub fn program_command(program: &Path, args: &[&str]) -> Command {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let is_script = program
.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
let mut command = if is_script {
let mut shell = Command::new("cmd.exe");
let command_line = format!("\"\"{}\" {}\"", program.display(), args.join(" "));
shell.args(["/D", "/S", "/C"]).arg(command_line);
shell
} else {
let mut direct = Command::new(program);
direct.args(args);
direct
};
command.creation_flags(CREATE_NO_WINDOW);
command
}
#[cfg(not(target_os = "windows"))]
pub fn program_command(program: &Path, args: &[&str]) -> Command {
let mut command = Command::new(program);
command.args(args);
command
}
fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
program_command(program, args).output().ok()
}
fn command_version(program: &Path) -> Option<String> {
run_program(program, &["--version"])
.and_then(version_from_output)
.or_else(|| run_program(program, &["-V"]).and_then(version_from_output))
}
fn candidate_key(path: &Path) -> String {
let value = path.to_string_lossy().to_string();
if cfg!(target_os = "windows") {
value.to_ascii_lowercase()
} else {
value
}
}
fn push_candidate(candidates: &mut Vec<PathBuf>, seen: &mut HashSet<String>, path: PathBuf) {
if seen.insert(candidate_key(&path)) {
candidates.push(path);
}
}
fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut Vec<PathBuf>) {
if depth == 0 || !root.is_dir() {
return;
}
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_named_files(&path, names, depth - 1, output);
} else if path.is_file()
&& path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| {
names
.iter()
.any(|candidate| name.eq_ignore_ascii_case(candidate))
})
{
output.push(path);
}
}
}
fn extension_codex_candidates(home: &Path) -> Vec<PathBuf> {
let roots = [
home.join(".cursor").join("extensions"),
home.join(".vscode").join("extensions"),
home.join(".vscode-insiders").join("extensions"),
home.join(".windsurf").join("extensions"),
];
let mut candidates = Vec::new();
for root in roots {
let Ok(entries) = fs::read_dir(root) else {
continue;
};
let mut extension_dirs = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| {
let lower = name.to_ascii_lowercase();
lower.starts_with("openai.chatgpt-")
|| lower.starts_with("openai.codex-")
})
})
.collect::<Vec<_>>();
extension_dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
for extension_dir in extension_dirs {
collect_named_files(
&extension_dir,
&["codex", "codex.exe", "codex.cmd"],
5,
&mut candidates,
);
}
}
candidates
}
#[cfg(target_os = "macos")]
pub fn macos_codex_app_version() -> Option<String> {
let candidates = [
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"),
home.join("Applications/ChatGPT.app/Contents/Resources/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
PathBuf::from("/Applications/OpenAI Codex.app/Contents/Resources/codex"),
PathBuf::from("/Applications/ChatGPT Codex.app/Contents/Resources/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
home.join(".local/bin/codex"),
home.join(".npm-global/bin/codex"),
home.join("Library/pnpm/codex"),
];
candidates.extend(extension_codex_candidates(home));
candidates
}
#[cfg(target_os = "windows")]
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Ok(appdata) = env::var("APPDATA") {
let appdata = PathBuf::from(appdata);
candidates.push(appdata.join("npm").join("codex.cmd"));
candidates.push(appdata.join("npm").join("codex.exe"));
for target in ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] {
candidates.push(
appdata
.join("npm/node_modules/@openai/codex/vendor")
.join(target)
.join("codex/codex.exe"),
);
}
}
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
let localappdata = PathBuf::from(localappdata);
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.exe"));
candidates.push(localappdata.join("Microsoft/WindowsApps/codex.cmd"));
for root in [
localappdata.join("Programs/ChatGPT"),
localappdata.join("Programs/Codex"),
localappdata.join("OpenAI/ChatGPT"),
] {
collect_named_files(&root, &["codex.exe", "codex.cmd"], 7, &mut candidates);
}
}
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Ok(program_files) = env::var(variable) {
for app in ["ChatGPT", "Codex"] {
collect_named_files(
&PathBuf::from(&program_files).join(app),
&["codex.exe", "codex.cmd"],
7,
&mut candidates,
);
}
}
}
candidates.extend(extension_codex_candidates(home));
candidates
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
fn platform_candidates(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/usr/bin/codex"),
PathBuf::from("/snap/bin/codex"),
home.join(".local/bin/codex"),
home.join(".npm-global/bin/codex"),
home.join(".local/share/pnpm/codex"),
];
candidates.extend(extension_codex_candidates(home));
candidates
}
#[cfg(target_os = "windows")]
fn windows_where_candidates() -> Vec<PathBuf> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut command = Command::new("where.exe");
let Ok(output) = command
.creation_flags(CREATE_NO_WINDOW)
.arg("codex")
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect()
}
#[cfg(not(target_os = "windows"))]
fn windows_where_candidates() -> Vec<PathBuf> {
Vec::new()
}
#[cfg(target_os = "macos")]
fn macos_app_version() -> Option<String> {
for app in [
"/Applications/ChatGPT.app",
"/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);
}
] {
let Some(output) =
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])
else {
continue;
};
if !output.status.success() {
continue;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !version.is_empty() && version != "(null)" {
let app_name = if app.ends_with("ChatGPT.app") {
"ChatGPT app"
} else {
"Codex app"
};
return Some(format!("{app_name} {version}"));
}
}
None
}
#[cfg(not(target_os = "macos"))]
pub fn macos_codex_app_version() -> Option<String> {
fn macos_app_version() -> Option<String> {
None
}
pub fn codex_executable_candidates() -> Vec<PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
candidates.extend(windows_where_candidates());
candidates.extend(platform_candidates(&home));
let mut seen = HashSet::new();
let mut unique = Vec::new();
for candidate in candidates {
push_candidate(&mut unique, &mut seen, candidate);
}
unique
}
pub fn detect_codex_version() -> Option<String> {
command_version("codex", &["--version"])
.or_else(|| command_version("codex", &["-V"]))
.or_else(macos_codex_app_version)
for candidate in codex_executable_candidates() {
let is_bare_command = candidate.components().count() == 1;
if is_bare_command || candidate.is_file() {
if let Some(version) = command_version(&candidate) {
return Some(version);
}
}
}
macos_app_version()
}
#[cfg(test)]
mod tests {
use super::version_line;
#[test]
fn version_parser_prefers_codex_line_over_warning() {
assert_eq!(
version_line(
"codex-cli 0.144.0-alpha.4\n",
"WARNING: could not create PATH aliases\n",
true,
)
.as_deref(),
Some("codex-cli 0.144.0-alpha.4")
);
}
#[test]
fn version_parser_accepts_successful_plain_version() {
assert_eq!(
version_line("0.42.0\n", "", true).as_deref(),
Some("0.42.0")
);
}
#[test]
fn version_parser_rejects_failed_error_output() {
assert_eq!(
version_line("", "error: command not found 127\n", false),
None
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
use crate::constants::{
AGENTS_FILENAME, AGENTS_MANAGED_BEGIN, AGENTS_MANAGED_END, AGENTS_TEMPLATE_PREFIX,
};
use crate::error::{CodexxError, Result};
use crate::file_io::{io_err, read_to_string_if_exists, write_text};
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) fn agents_path(codex_dir: &Path) -> PathBuf {
codex_dir.join(AGENTS_FILENAME)
}
pub(crate) fn managed_agents_bounds(content: &str) -> Result<Option<(usize, usize)>> {
let begins = content
.match_indices(AGENTS_MANAGED_BEGIN)
.map(|(index, _)| index)
.collect::<Vec<_>>();
let ends = content
.match_indices(AGENTS_MANAGED_END)
.map(|(index, _)| index)
.collect::<Vec<_>>();
if begins.is_empty() && ends.is_empty() {
return Ok(None);
}
if begins.len() != 1 || ends.len() != 1 || begins[0] >= ends[0] {
return Err(CodexxError::Config(
"AGENTS.md 中的 Codex-X 受管区块标记不完整或重复,请先修复 BEGIN/END 标记".to_string(),
));
}
Ok(Some((begins[0], ends[0] + AGENTS_MANAGED_END.len())))
}
fn remove_managed_agents_block(content: &str) -> Result<(String, bool)> {
let Some((start, end)) = managed_agents_bounds(content)? else {
return Ok((content.to_string(), false));
};
let before = content[..start].trim_end();
let after = content[end..].trim_start();
let merged = match (before.is_empty(), after.is_empty()) {
(true, true) => String::new(),
(false, true) => format!("{}\n", before),
(true, false) => format!("{}\n", after.trim_end()),
(false, false) => format!("{}\n\n{}\n", before, after.trim_end()),
};
Ok((merged, true))
}
pub(crate) fn managed_agents_template_key_from_content(content: &str) -> Option<String> {
let (start, end) = managed_agents_bounds(content).ok().flatten()?;
content[start..end].lines().find_map(|line| {
line.trim()
.strip_prefix(AGENTS_TEMPLATE_PREFIX)
.and_then(|value| value.strip_suffix("-->"))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
}
pub(crate) fn managed_agents_template_key(codex_dir: &Path) -> Result<Option<String>> {
let path = agents_path(codex_dir);
let content = read_to_string_if_exists(&path)?;
Ok(managed_agents_template_key_from_content(&content))
}
pub(crate) fn install_managed_agents_block(
codex_dir: &Path,
template_key: &str,
content: &str,
) -> Result<()> {
let path = agents_path(codex_dir);
let existing = read_to_string_if_exists(&path)?;
let (base, _) = remove_managed_agents_block(&existing)?;
let managed = format!(
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
content.trim()
);
let next = if base.trim().is_empty() {
format!("{managed}\n")
} else {
format!("{}\n\n{managed}\n", base.trim_end())
};
write_text(&path, &next)
}
pub(crate) fn uninstall_managed_agents_block(codex_dir: &Path) -> Result<bool> {
let path = agents_path(codex_dir);
if !path.exists() {
return Ok(false);
}
let existing = read_to_string_if_exists(&path)?;
let (next, removed) = remove_managed_agents_block(&existing)?;
if !removed {
return Ok(false);
}
if next.trim().is_empty() {
fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
} else {
write_text(&path, &next)?;
}
Ok(true)
}
+134
View File
@@ -0,0 +1,134 @@
mod catalog;
mod managed_agents;
mod store;
mod types;
pub(crate) use catalog::{
builtin_prompt_content, builtin_prompt_status_inner, bundled_prompt_meta, bundled_prompt_metas,
refresh_builtin_prompts_with_active,
};
pub(crate) use managed_agents::{
agents_path, install_managed_agents_block, managed_agents_bounds, managed_agents_template_key,
uninstall_managed_agents_block,
};
pub(crate) use store::{
delete_prompt_inner, get_saved_prompt_inner, list_saved_prompts_inner,
normalize_prompt_filename, save_prompt_inner,
};
pub(crate) use types::{BuiltinPromptStatus, SavedPrompt};
#[cfg(test)]
pub(crate) use catalog::{
cached_prompt_fallback_statuses, delete_cached_prompt_ids, github_prompt_catalog_from_entries,
jsdelivr_prompt_catalog_from_entries, prompt_content_source_urls, stable_remote_prompt_id,
stale_cached_prompt_ids,
};
#[cfg(test)]
pub(crate) use managed_agents::managed_agents_template_key_from_content;
#[cfg(test)]
pub(crate) use types::{CachedBuiltinPrompt, GithubContentEntry};
use crate::error::Result;
use crate::file_io::{parse_toml_document, read_to_string_if_exists};
use crate::paths::home_dir;
use crate::{config_path, sanitize_id, string_value};
use catalog::cached_builtin_prompts;
use std::path::{Path, PathBuf};
use store::find_saved_prompt_by_current_file;
fn builtin_prompt_id_for_filename(filename: &str) -> Result<Option<String>> {
if let Some(meta) = bundled_prompt_metas()
.into_iter()
.find(|item| item.filename.eq_ignore_ascii_case(filename))
{
return Ok(Some(meta.id.to_string()));
}
Ok(cached_builtin_prompts()?
.into_iter()
.find(|item| item.filename.eq_ignore_ascii_case(filename))
.map(|item| item.id))
}
fn saved_prompt_id_for_filename(filename: &str) -> Result<Option<String>> {
Ok(list_saved_prompts_inner()?
.into_iter()
.find(|item| item.filename.eq_ignore_ascii_case(filename))
.map(|item| item.id))
}
pub(crate) fn prompt_template_key_for_instruction(value: &str) -> Result<Option<String>> {
let normalized = value.replace('\\', "/");
let filename = normalized.rsplit('/').next().unwrap_or(&normalized);
if let Some(id) = builtin_prompt_id_for_filename(filename)? {
return Ok(Some(format!("builtin:{id}")));
}
Ok(saved_prompt_id_for_filename(filename)?.map(|id| format!("saved:{id}")))
}
pub(crate) 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)
}
}
pub(crate) 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 prompt_template_key_for_instruction(&current)?.is_some() {
return Ok(None);
}
let path = resolve_instruction_path(codex_dir, &current);
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 normalized_filename = normalize_prompt_filename(&file_name, "external-prompt");
let existing = find_saved_prompt_by_current_file(&file_name, &content)?;
let (id, title, filename) = existing
.map(|prompt| (prompt.id, prompt.title, prompt.filename))
.unwrap_or_else(|| {
(
format!("external-{}", sanitize_id(stem)),
format!("外部提示词 · {stem}"),
normalized_filename,
)
});
save_prompt_inner(SavedPrompt {
id,
title,
filename,
content,
})
.map(Some)
}
+169
View File
@@ -0,0 +1,169 @@
use super::types::SavedPrompt;
use crate::error::{CodexxError, Result};
use crate::{now_rfc3339, open_db};
use rusqlite::params;
pub(crate) fn normalize_prompt_filename(input: &str, fallback: &str) -> String {
let raw = input.trim().trim_end_matches(".md");
let base = if raw.is_empty() { fallback } else { raw };
let mut out = String::new();
let mut last_dash = false;
for ch in base.to_ascii_lowercase().chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let out = out.trim_matches('-');
format!("{}.md", if out.is_empty() { "custom-prompt" } else { out })
}
fn canonical_prompt_content(input: &str) -> String {
input
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim()
.to_string()
}
pub(crate) fn list_saved_prompts_inner() -> Result<Vec<SavedPrompt>> {
let conn = open_db()?;
let mut stmt = conn
.prepare("SELECT id, title, filename, content FROM prompts ORDER BY updated_at DESC, created_at DESC")
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok(SavedPrompt {
id: row.get(0)?,
title: row.get(1)?,
filename: row.get(2)?,
content: row.get(3)?,
})
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut prompts = Vec::new();
for row in rows {
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
let filename_key = prompt.filename.to_ascii_lowercase();
let content_key = canonical_prompt_content(&prompt.content);
let duplicate_index = prompts.iter().position(|existing: &SavedPrompt| {
existing.filename.to_ascii_lowercase() == filename_key
|| (canonical_prompt_content(&existing.content) == content_key
&& (existing.id.starts_with("external-") || prompt.id.starts_with("external-")))
});
if let Some(index) = duplicate_index {
let existing_is_external = prompts[index].id.starts_with("external-");
let prompt_is_external = prompt.id.starts_with("external-");
if existing_is_external && !prompt_is_external {
prompts[index] = prompt;
}
continue;
}
prompts.push(prompt);
}
Ok(prompts)
}
pub(crate) fn save_prompt_inner(prompt: SavedPrompt) -> Result<SavedPrompt> {
let conn = open_db()?;
let now = now_rfc3339();
conn.execute(
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
filename = excluded.filename,
content = excluded.content,
updated_at = excluded.updated_at",
params![
prompt.id,
prompt.title,
prompt.filename,
prompt.content,
now
],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
list_saved_prompts_inner()?
.into_iter()
.find(|p| p.id == prompt.id)
.ok_or_else(|| CodexxError::Database("prompt saved but not found".to_string()))
}
pub(crate) fn get_saved_prompt_inner(id: &str) -> Result<SavedPrompt> {
list_saved_prompts_inner()?
.into_iter()
.find(|p| p.id == id)
.ok_or_else(|| CodexxError::Config(format!("提示词不存在: {id}")))
}
pub(crate) fn delete_prompt_inner(id: &str) -> Result<()> {
let conn = open_db()?;
conn.execute("DELETE FROM prompts WHERE id = ?1", params![id])
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
fn find_saved_prompt_by_content(content: &str) -> Result<Option<SavedPrompt>> {
let conn = open_db()?;
let mut stmt = conn
.prepare("SELECT id, title, filename, content FROM prompts ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC")
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok(SavedPrompt {
id: row.get(0)?,
title: row.get(1)?,
filename: row.get(2)?,
content: row.get(3)?,
})
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let target = canonical_prompt_content(content);
for row in rows {
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
if canonical_prompt_content(&prompt.content) == target {
return Ok(Some(prompt));
}
}
Ok(None)
}
pub(super) fn find_saved_prompt_by_current_file(
filename: &str,
content: &str,
) -> Result<Option<SavedPrompt>> {
if let Some(prompt) = find_saved_prompt_by_content(content)? {
return Ok(Some(prompt));
}
let normalized_filename = normalize_prompt_filename(filename, "external-prompt");
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT id, title, filename, content FROM prompts
WHERE lower(filename) = lower(?1)
ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC
LIMIT 1",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
match stmt.query_row([normalized_filename], |row| {
Ok(SavedPrompt {
id: row.get(0)?,
title: row.get(1)?,
filename: row.get(2)?,
content: row.get(3)?,
})
}) {
Ok(mut prompt) => {
if canonical_prompt_content(&prompt.content) != canonical_prompt_content(content) {
prompt.content = content.to_string();
}
Ok(Some(prompt))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(CodexxError::Database(e.to_string())),
}
}
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedPrompt {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) filename: String,
pub(crate) content: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BuiltinPromptStatus {
pub(crate) id: String,
pub(crate) filename: String,
pub(crate) title: String,
pub(crate) subtitle: String,
pub(crate) badge: String,
pub(crate) source_url: String,
pub(crate) cached: bool,
pub(crate) updated: bool,
pub(crate) content_source: String,
pub(crate) sync_issue: Option<String>,
pub(crate) checked_at: Option<String>,
pub(crate) message: String,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct BundledPromptMeta {
pub(super) id: &'static str,
pub(super) filename: &'static str,
pub(super) title: &'static str,
pub(super) subtitle: &'static str,
pub(super) badge: &'static str,
pub(super) content: &'static str,
}
#[derive(Debug, Clone)]
pub(crate) struct CachedBuiltinPrompt {
pub(crate) id: String,
pub(crate) filename: String,
pub(crate) source_url: String,
pub(crate) content: String,
pub(crate) checked_at: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GithubContentEntry {
pub(crate) name: String,
#[serde(rename = "type")]
pub(crate) kind: String,
pub(crate) download_url: Option<String>,
}
@@ -0,0 +1,443 @@
use super::{
custom_provider_id, experimental_bearer_token_from_doc, list_saved_providers_on_connection,
normalize_saved_provider, open_store, upsert_provider_on_connection, ProviderUpsertKind,
ProviderUpsertMode, SavedProvider,
};
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
use crate::error::{CodexxError, Result};
use crate::sqlite_utils::table_column_set;
use crate::string_value;
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use toml_edit::{DocumentMut, Table};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ImportResult {
imported: usize,
added: usize,
updated: usize,
merged: usize,
skipped: usize,
warnings: Vec<String>,
providers: Vec<SavedProvider>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialAuthCandidate {
auth_json: String,
model: Option<String>,
source: String,
}
#[derive(Debug, Clone)]
pub(crate) struct CcSwitchCodexRow {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) settings_config: String,
pub(crate) category: Option<String>,
}
pub(crate) fn is_official_ccswitch_row(row: &CcSwitchCodexRow) -> bool {
row.id.trim().eq_ignore_ascii_case("codex-official")
|| row
.category
.as_deref()
.is_some_and(|value| value.trim().eq_ignore_ascii_case("official"))
}
pub(crate) fn read_ccswitch_codex_rows(conn: &Connection) -> Result<Vec<CcSwitchCodexRow>> {
let provider_columns = table_column_set(conn, "providers")?;
let category_column = if provider_columns.contains("category") {
"category"
} else {
"NULL"
};
let provider_query = format!(
"SELECT id, name, settings_config, {category_column} FROM providers
WHERE app_type = 'codex' ORDER BY sort_index ASC, created_at ASC"
);
let mut stmt = conn
.prepare(&provider_query)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok(CcSwitchCodexRow {
id: row.get::<_, String>(0)?,
name: row.get::<_, String>(1)?,
settings_config: row.get::<_, String>(2)?,
category: row.get::<_, Option<String>>(3)?,
})
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut result = Vec::new();
for row in rows {
result.push(row.map_err(|e| CodexxError::Database(e.to_string()))?);
}
Ok(result)
}
#[derive(Debug, Clone)]
pub(crate) struct CcSwitchCodexSection {
pub(crate) id: String,
pub(crate) name: Option<String>,
pub(crate) base_url: String,
pub(crate) model: Option<String>,
pub(crate) wire_api: String,
pub(crate) requires_openai_auth: bool,
pub(crate) experimental_bearer_token: Option<String>,
}
fn table_string(table: &Table, key: &str) -> Option<String> {
table
.get(key)
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
}
fn ccswitch_auth_api_key(settings: &Value) -> Option<String> {
settings
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
}
pub(super) fn codex_section_from_table(
id: &str,
table: &Table,
model: Option<String>,
) -> Option<CcSwitchCodexSection> {
let base_url = table_string(table, "base_url")?
.trim_end_matches('/')
.to_string();
if base_url.is_empty() {
return None;
}
Some(CcSwitchCodexSection {
id: id.to_string(),
name: table_string(table, "name"),
base_url,
model,
wire_api: table_string(table, "wire_api").unwrap_or_else(|| "responses".to_string()),
requires_openai_auth: table
.get("requires_openai_auth")
.and_then(|item| item.as_bool())
.unwrap_or(false),
experimental_bearer_token: table_string(table, "experimental_bearer_token"),
})
}
pub(crate) fn codex_sections_from_config(config_text: &str) -> Vec<CcSwitchCodexSection> {
let Ok(doc) = config_text.parse::<DocumentMut>() else {
return Vec::new();
};
let model = string_value(&doc, "model");
let Some(providers) = doc.get("model_providers").and_then(|item| item.as_table()) else {
return Vec::new();
};
providers
.iter()
.filter_map(|(id, item)| {
item.as_table()
.and_then(|table| codex_section_from_table(id, table, model.clone()))
})
.collect()
}
fn select_ccswitch_section_for_row(
row: &CcSwitchCodexRow,
settings: &Value,
global_sections: &HashMap<String, CcSwitchCodexSection>,
) -> Option<CcSwitchCodexSection> {
let provider_id = custom_provider_id(&row.id);
if let Some(section) = global_sections.get(&provider_id) {
return Some(section.clone());
}
if let Some(section) = global_sections.get(row.id.trim()) {
return Some(section.clone());
}
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
let doc = config_text.parse::<DocumentMut>().ok()?;
let model = string_value(&doc, "model");
let active_provider = string_value(&doc, "model_provider");
let providers = doc.get("model_providers").and_then(|item| item.as_table());
if let Some(providers) = providers {
for exact_id in [provider_id.as_str(), row.id.trim()] {
if let Some(section) = providers
.get(exact_id)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
{
return Some(section);
}
}
if active_provider.as_deref() == Some(row.id.trim())
|| active_provider.as_deref() == Some(provider_id.as_str())
{
if let Some(active) = active_provider.as_deref() {
if let Some(section) = providers
.get(active)
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table(active, table, model.clone()))
{
return Some(section);
}
}
}
// Legacy cc-switch/custom templates often store every third-party provider
// under `[model_providers.custom]`. Only use it when the row's own config
// explicitly activates custom or contains no other provider identity.
if active_provider
.as_deref()
.is_none_or(|active| active == "custom")
{
if let Some(section) = providers
.get("custom")
.and_then(|item| item.as_table())
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
{
return Some(section);
}
}
}
doc.get("base_url")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|base_url| CcSwitchCodexSection {
id: provider_id,
name: None,
base_url: base_url.trim_end_matches('/').to_string(),
model,
wire_api: "responses".to_string(),
requires_openai_auth: false,
experimental_bearer_token: experimental_bearer_token_from_doc(
&doc,
active_provider.as_deref(),
),
})
}
pub(crate) fn build_ccswitch_codex_provider(
row: &CcSwitchCodexRow,
global_sections: &HashMap<String, CcSwitchCodexSection>,
) -> Option<SavedProvider> {
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
Some(SavedProvider {
id: custom_provider_id(&row.id),
provider_name: if row.name.trim().is_empty() {
section.name.unwrap_or_else(|| row.id.clone())
} else {
row.name.trim().to_string()
},
base_url: section.base_url,
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
api_key,
toml_config: None,
wire_api: section.wire_api,
requires_openai_auth: section.requires_openai_auth,
})
}
pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Result<ImportResult> {
let db = path
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or(default_ccswitch_db_path()?);
if !db.exists() {
let candidates = ccswitch_db_candidates()?
.into_iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("\n- ");
return Err(CodexxError::Config(format!(
"cc-switch 数据库不存在: {}\n已检查候选路径:\n- {}",
db.display(),
candidates
)));
}
let conn = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
CodexxError::Database(format!("打开 cc-switch 数据库失败 {}: {e}", db.display()))
})?;
let rows_vec = read_ccswitch_codex_rows(&conn)?;
let mut global_sections: HashMap<String, CcSwitchCodexSection> = HashMap::new();
for row in &rows_vec {
if is_official_ccswitch_row(row) {
continue;
}
let Ok(settings) = serde_json::from_str::<Value>(&row.settings_config) else {
continue;
};
let Some(config_text) = settings.get("config").and_then(Value::as_str) else {
continue;
};
for section in codex_sections_from_config(config_text) {
if !global_sections.contains_key(&section.id) {
global_sections.insert(section.id.clone(), section);
}
}
}
let mut imported = 0usize;
let mut added = 0usize;
let mut updated = 0usize;
let mut merged = 0usize;
let mut skipped = 0usize;
let mut warnings = Vec::new();
let mut local_conn = open_store()?;
let transaction = local_conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| CodexxError::Database(e.to_string()))?;
for row in rows_vec {
if is_official_ccswitch_row(&row) {
skipped += 1;
warnings.push(format!(
"跳过 {} ({}):官方认证不作为第三方供应商导入",
row.name, row.id
));
continue;
}
match build_ccswitch_codex_provider(&row, &global_sections) {
Some(provider) => {
let provider = normalize_saved_provider(provider)?;
let result = upsert_provider_on_connection(
&transaction,
provider,
ProviderUpsertMode::Imported,
)?;
match result.kind {
ProviderUpsertKind::Added => added += 1,
ProviderUpsertKind::Updated => updated += 1,
ProviderUpsertKind::Merged => merged += 1,
}
imported += 1;
}
None => {
skipped += 1;
warnings.push(format!(
"跳过 {} ({}):未找到可用 config/base_url,可能是官方登录或空模板",
row.name, row.id
));
}
}
}
transaction
.commit()
.map_err(|e| CodexxError::Database(e.to_string()))?;
let providers = list_saved_providers_on_connection(&local_conn)?;
Ok(ImportResult {
imported,
added,
updated,
merged,
skipped,
warnings,
providers,
})
}
pub(crate) fn read_ccswitch_official_auth_inner(
path: Option<String>,
) -> Result<Option<OfficialAuthCandidate>> {
let db = path
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or(default_ccswitch_db_path()?);
if !db.exists() {
return Ok(None);
}
let conn = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
CodexxError::Database(format!("打开 cc-switch 数据库失败 {}: {e}", db.display()))
})?;
let mut stmt = conn
.prepare(
"SELECT id, name, settings_config FROM providers
WHERE app_type = 'codex' AND (id = 'codex-official' OR category = 'official')
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
LIMIT 1",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut rows = stmt
.query([])
.map_err(|e| CodexxError::Database(e.to_string()))?;
let Some(row) = rows
.next()
.map_err(|e| CodexxError::Database(e.to_string()))?
else {
return Ok(None);
};
let id: String = row
.get(0)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let name: String = row
.get(1)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let settings_config: String = row
.get(2)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let settings: Value = serde_json::from_str(&settings_config).map_err(|e| {
CodexxError::Database(format!("cc-switch official settings JSON 解析失败: {e}"))
})?;
let auth = settings
.get("auth")
.cloned()
.filter(|value| value.is_object())
.ok_or_else(|| {
CodexxError::Database("cc-switch official provider 缺少 auth object".to_string())
})?;
let model = settings
.get("config")
.and_then(Value::as_str)
.and_then(|text| text.parse::<DocumentMut>().ok())
.and_then(|doc| string_value(&doc, "model"));
let auth_json = serde_json::to_string_pretty(&auth)
.map_err(|e| CodexxError::Database(format!("官方 auth JSON 格式化失败: {e}")))?;
Ok(Some(OfficialAuthCandidate {
auth_json,
model,
source: format!("cc-switch:{name}:{id}"),
}))
}
@@ -0,0 +1,437 @@
use crate::error::{CodexxError, Result};
#[cfg(test)]
use crate::remote::ensure_crypto_provider;
use crate::remote::{remote_client, remote_request_error, RemoteSource};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::time::Instant;
const MODELS_SOURCE_KEY: &str = "获取模型列表";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderConnectionResult {
pub(crate) ok: bool,
pub(crate) status: Option<u16>,
pub(crate) message: String,
pub(crate) duration_ms: u128,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderModel {
pub(crate) id: String,
pub(crate) created: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderModelsResult {
pub(crate) models: Vec<ProviderModel>,
pub(crate) status: u16,
pub(crate) duration_ms: u128,
}
#[derive(Debug, Deserialize)]
struct ModelsPayload {
data: Vec<ModelPayload>,
}
#[derive(Debug, Deserialize)]
struct ModelPayload {
id: String,
#[serde(default)]
created: Option<serde_json::Value>,
}
enum ProviderModelsAttempt {
Success(ProviderModelsResult),
HttpError { status: u16, duration_ms: u128 },
}
fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
let trimmed = base_url.trim();
if trimmed.is_empty() {
return Err(CodexxError::Config("base_url 不能为空".to_string()));
}
let mut url = reqwest::Url::parse(trimmed)
.map_err(|_| CodexxError::Config("base_url 格式不正确".to_string()))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(CodexxError::Config(
"base_url 必须是有效的 http:// 或 https:// 地址".to_string(),
));
}
let segments = url
.path_segments()
.ok_or_else(|| CodexxError::Config("base_url 格式不正确".to_string()))?
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let already_models = segments.len() >= 2
&& segments[segments.len() - 2].eq_ignore_ascii_case("v1")
&& segments[segments.len() - 1].eq_ignore_ascii_case("models");
let already_v1 = segments
.last()
.is_some_and(|segment| segment.eq_ignore_ascii_case("v1"));
{
let mut path = url
.path_segments_mut()
.map_err(|_| CodexxError::Config("base_url 格式不正确".to_string()))?;
path.pop_if_empty();
if !already_models {
if !already_v1 {
path.push("v1");
}
path.push("models");
}
}
url.set_fragment(None);
Ok(url)
}
fn parse_created(value: Option<serde_json::Value>) -> Option<i64> {
value.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_str().and_then(|text| text.parse::<i64>().ok()))
})
}
fn compare_digit_runs(left: &[u8], right: &[u8]) -> Ordering {
let left_significant = left
.iter()
.position(|byte| *byte != b'0')
.map_or(&left[left.len()..], |index| &left[index..]);
let right_significant = right
.iter()
.position(|byte| *byte != b'0')
.map_or(&right[right.len()..], |index| &right[index..]);
left_significant
.len()
.cmp(&right_significant.len())
.then_with(|| left_significant.cmp(right_significant))
}
fn natural_model_id_cmp(left: &str, right: &str) -> Ordering {
let left = left.as_bytes();
let right = right.as_bytes();
let (mut left_index, mut right_index) = (0, 0);
while left_index < left.len() && right_index < right.len() {
if left[left_index].is_ascii_digit() && right[right_index].is_ascii_digit() {
let left_end = left[left_index..]
.iter()
.position(|byte| !byte.is_ascii_digit())
.map_or(left.len(), |offset| left_index + offset);
let right_end = right[right_index..]
.iter()
.position(|byte| !byte.is_ascii_digit())
.map_or(right.len(), |offset| right_index + offset);
let order =
compare_digit_runs(&left[left_index..left_end], &right[right_index..right_end]);
if order != Ordering::Equal {
return order;
}
left_index = left_end;
right_index = right_end;
continue;
}
let order = left[left_index]
.to_ascii_lowercase()
.cmp(&right[right_index].to_ascii_lowercase());
if order != Ordering::Equal {
return order;
}
left_index += 1;
right_index += 1;
}
left.len().cmp(&right.len()).then_with(|| left.cmp(right))
}
fn parse_models(body: &str) -> Result<Vec<ProviderModel>> {
let payload: ModelsPayload = serde_json::from_str(body)
.map_err(|_| CodexxError::Config("模型列表返回格式不正确".to_string()))?;
let mut models = Vec::<ProviderModel>::new();
let mut indexes = HashMap::<String, usize>::new();
for model in payload.data {
let id = model.id.trim();
if id.is_empty() {
continue;
}
let created = parse_created(model.created);
if let Some(index) = indexes.get(id).copied() {
if created > models[index].created {
models[index].created = created;
}
continue;
}
indexes.insert(id.to_string(), models.len());
models.push(ProviderModel {
id: id.to_string(),
created,
});
}
models.sort_by(|left, right| {
right
.created
.cmp(&left.created)
.then_with(|| natural_model_id_cmp(&right.id, &left.id))
});
Ok(models)
}
fn request_provider_models_with_client(
client: &Client,
base_url: &str,
api_key: Option<&str>,
) -> Result<ProviderModelsAttempt> {
let url = provider_models_url(base_url)?;
let source = RemoteSource::new(MODELS_SOURCE_KEY, url.as_str(), Some("application/json"));
let mut request = client
.get(url.as_str())
.header(reqwest::header::ACCEPT, "application/json");
if let Some(api_key) = api_key.map(str::trim).filter(|key| !key.is_empty()) {
request = request.bearer_auth(api_key);
}
let started = Instant::now();
let response = request
.send()
.map_err(|error| remote_request_error(&source, &error))?;
let duration_ms = started.elapsed().as_millis();
let status = response.status().as_u16();
if !response.status().is_success() {
return Ok(ProviderModelsAttempt::HttpError {
status,
duration_ms,
});
}
let body = response
.text()
.map_err(|_| CodexxError::Config("模型列表读取失败".to_string()))?;
Ok(ProviderModelsAttempt::Success(ProviderModelsResult {
models: parse_models(&body)?,
status,
duration_ms,
}))
}
fn request_provider_models(base_url: &str, api_key: Option<&str>) -> Result<ProviderModelsAttempt> {
let client = remote_client()?;
request_provider_models_with_client(&client, base_url, api_key)
}
pub(crate) fn provider_status_result(status: u16, duration_ms: u128) -> ProviderConnectionResult {
ProviderConnectionResult {
ok: (200..300).contains(&status),
status: Some(status),
message: if (200..300).contains(&status) {
format!("{duration_ms} ms")
} else if status == 401 || status == 403 {
format!("HTTP {status} · {duration_ms} ms(认证失败或无权限)")
} else {
format!("HTTP {status} · {duration_ms} ms")
},
duration_ms,
}
}
pub(crate) fn test_provider_connection_inner(
base_url: String,
api_key: Option<String>,
) -> Result<ProviderConnectionResult> {
match request_provider_models(&base_url, api_key.as_deref())? {
ProviderModelsAttempt::Success(result) => {
Ok(provider_status_result(result.status, result.duration_ms))
}
ProviderModelsAttempt::HttpError {
status,
duration_ms,
} => Ok(provider_status_result(status, duration_ms)),
}
}
pub(crate) fn fetch_provider_models_inner(
base_url: String,
api_key: Option<String>,
) -> Result<ProviderModelsResult> {
match request_provider_models(&base_url, api_key.as_deref())? {
ProviderModelsAttempt::Success(result) => Ok(result),
ProviderModelsAttempt::HttpError { status, .. } => {
Err(CodexxError::Config(if matches!(status, 401 | 403) {
format!("获取模型列表失败(HTTP {status},请检查 API Key)")
} else {
format!("获取模型列表失败(HTTP {status})")
}))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
fn direct_client() -> Client {
ensure_crypto_provider();
Client::builder()
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
.expect("build test client")
}
fn serve_once(status: u16, body: &'static str) -> (String, thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept request");
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set read timeout");
let mut bytes = [0_u8; 8192];
let read = stream.read(&mut bytes).expect("read request");
let status_text = if status == 200 { "OK" } else { "Error" };
let response = format!(
"HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("write response");
String::from_utf8_lossy(&bytes[..read]).into_owned()
});
(base_url, server)
}
#[test]
fn models_url_adds_exactly_one_v1_segment() {
for (base, expected) in [
("https://example.com", "https://example.com/v1/models"),
("https://example.com/v1", "https://example.com/v1/models"),
("https://example.com/v1/", "https://example.com/v1/models"),
(
"https://example.com/openai",
"https://example.com/openai/v1/models",
),
] {
assert_eq!(provider_models_url(base).unwrap().as_str(), expected);
}
}
#[test]
fn fetches_models_with_bearer_auth_and_deduplicates_ids() {
let body = r#"{"data":[{"id":" gpt-5.6-sol ","created":20},{"id":"gpt-5.5","created":"10"},{"id":"gpt-5.6-sol","created":30},{"id":" "}]}"#;
let (base_url, server) = serve_once(200, body);
let attempt = request_provider_models_with_client(
&direct_client(),
&base_url,
Some("sk-private-test"),
)
.expect("request succeeds");
let ProviderModelsAttempt::Success(result) = attempt else {
panic!("expected successful models response");
};
assert_eq!(
result.models,
vec![
ProviderModel {
id: "gpt-5.6-sol".to_string(),
created: Some(30),
},
ProviderModel {
id: "gpt-5.5".to_string(),
created: Some(10),
},
]
);
let request = server.join().expect("join mock server");
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
assert!(request
.to_ascii_lowercase()
.contains("authorization: bearer sk-private-test"));
}
#[test]
fn sorts_by_created_then_natural_model_version_descending() {
let models = parse_models(
r#"{"data":[{"id":"gpt-5.5","created":20},{"id":"gpt-5.6-sol","created":20},{"id":"gpt-5.9"},{"id":"gpt-5.10"},{"id":"older-by-name","created":30}]}"#,
)
.expect("parse models");
assert_eq!(
models
.iter()
.map(|model| model.id.as_str())
.collect::<Vec<_>>(),
[
"older-by-name",
"gpt-5.6-sol",
"gpt-5.5",
"gpt-5.10",
"gpt-5.9",
]
);
}
#[test]
fn http_errors_are_not_reported_as_connected_or_leaked() {
let private_body = r#"{"error":"secret upstream response"}"#;
let (base_url, server) = serve_once(403, private_body);
let attempt = request_provider_models_with_client(
&direct_client(),
&base_url,
Some("sk-private-test"),
)
.expect("HTTP status remains an inspectable result");
let ProviderModelsAttempt::HttpError {
status,
duration_ms,
} = attempt
else {
panic!("expected HTTP error");
};
let result = provider_status_result(status, duration_ms);
assert!(!result.ok);
assert_eq!(result.status, Some(403));
assert!(!result.message.contains("secret upstream response"));
assert!(!result.message.contains("sk-private-test"));
assert!(!result.message.contains(&base_url));
server.join().expect("join mock server");
}
#[test]
fn invalid_payload_error_does_not_include_response_body() {
let private_body = r#"{"private":"secret response"}"#;
let (base_url, server) = serve_once(200, private_body);
let error = request_provider_models_with_client(
&direct_client(),
&base_url,
Some("sk-private-test"),
)
.err()
.expect("invalid payload fails");
let message = error.to_string();
assert!(!message.contains("secret response"));
assert!(!message.contains("sk-private-test"));
assert!(!message.contains(&base_url));
server.join().expect("join mock server");
}
}
@@ -0,0 +1,436 @@
use super::ccswitch::codex_section_from_table;
use super::{
custom_provider_id, experimental_bearer_token_from_doc, reserved_codex_provider_id,
save_detected_provider_inner, SavedProvider,
};
use crate::backups::create_backup;
use crate::error::{CodexxError, Result};
use crate::file_io::{
ensure_directory, io_err, json_err, parse_toml_document, read_to_string_if_exists, write_json,
write_text,
};
use crate::state::{build_state, ActionResult};
use crate::toml_utils::ensure_table;
use crate::{auth_path, config_path, resolve_codex_dir, string_value};
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use toml_edit::{value, DocumentMut};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderInput {
pub(crate) config_dir: Option<String>,
#[serde(rename = "providerId")]
pub(crate) _provider_id: Option<String>,
pub(crate) provider_name: String,
pub(crate) base_url: String,
pub(crate) model: String,
pub(crate) api_key: Option<String>,
pub(crate) wire_api: Option<String>,
pub(crate) requires_openai_auth: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderTomlInput {
pub(crate) config_dir: Option<String>,
pub(crate) config_text: String,
pub(crate) api_key: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialConfigInput {
pub(crate) config_dir: Option<String>,
pub(crate) model: Option<String>,
pub(crate) auth_json: Option<String>,
}
fn live_auth_api_key(codex_dir: &Path) -> Result<Option<String>> {
let path = auth_path(codex_dir);
if !path.exists() {
return Ok(None);
}
let text = fs::read_to_string(&path).map_err(|e| io_err(&path, e))?;
let auth: Value = serde_json::from_str(&text).map_err(|e| json_err(&path, e))?;
Ok(auth
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.map(str::trim)
.filter(|key| !key.is_empty())
.map(ToString::to_string))
}
fn strip_provider_bearer_tokens(doc: &mut DocumentMut) {
doc.as_table_mut().remove("experimental_bearer_token");
if let Some(providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
for (_, item) in providers.iter_mut() {
if let Some(table) = item.as_table_mut() {
table.remove("experimental_bearer_token");
}
}
}
}
pub(crate) fn detected_live_custom_provider(codex_dir: &Path) -> Result<Option<SavedProvider>> {
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
if text.trim().is_empty() {
return Ok(None);
}
let mut doc = parse_toml_document(&cfg, &text)?;
let Some(provider_id) = string_value(&doc, "model_provider") else {
return Ok(None);
};
if reserved_codex_provider_id(&provider_id) {
return Ok(None);
}
let Some(model) = string_value(&doc, "model") else {
return Ok(None);
};
let Some(provider_table) = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get(provider_id.as_str()))
.and_then(|item| item.as_table())
else {
return Ok(None);
};
let Some(section) = codex_section_from_table(&provider_id, provider_table, Some(model.clone()))
else {
return Ok(None);
};
let api_key = match experimental_bearer_token_from_doc(&doc, Some(&provider_id)) {
Some(api_key) => Some(api_key),
None => live_auth_api_key(codex_dir)?,
};
strip_provider_bearer_tokens(&mut doc);
let provider_name = section
.name
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| provider_id.clone());
let toml_config = doc.to_string().trim_end().to_string();
Ok(Some(SavedProvider {
id: custom_provider_id(&provider_id),
provider_name,
base_url: section.base_url,
model,
api_key,
toml_config: (!toml_config.is_empty()).then_some(toml_config),
wire_api: section.wire_api,
requires_openai_auth: section.requires_openai_auth,
}))
}
fn persist_detected_live_custom_provider(codex_dir: &Path) -> Result<()> {
if let Some(provider) = detected_live_custom_provider(codex_dir)? {
save_detected_provider_inner(provider)?;
}
Ok(())
}
fn set_top_level_defaults(doc: &mut DocumentMut) {
if doc.get("model_reasoning_effort").is_none() {
doc["model_reasoning_effort"] = value("high");
}
if doc.get("disable_response_storage").is_none() {
doc["disable_response_storage"] = value(true);
}
}
fn set_provider_bearer_token(doc: &mut DocumentMut, token: &str) {
let token = token.trim();
if token.is_empty() {
return;
}
let provider_id = string_value(doc, "model_provider");
if let Some(provider_id) = provider_id {
if let Some(provider_table) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
.and_then(|providers| providers.get_mut(provider_id.as_str()))
.and_then(|item| item.as_table_mut())
{
provider_table["experimental_bearer_token"] = value(token);
return;
}
}
doc["experimental_bearer_token"] = value(token);
}
fn apply_official_config(
config_dir: Option<String>,
model: Option<String>,
auth_json: Option<String>,
action: &str,
message: &str,
) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
let backup_id = create_backup(&codex_dir, action)?;
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
// 官方模式显式指向 Codex 内置 OpenAI provider,避免从第三方 custom
// 切回官方时仍被旧版 Codex/缓存误判为自定义路由。
doc["model_provider"] = value("openai");
let mut remove_model_providers = false;
if let Some(providers) = doc
.as_table_mut()
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
providers.remove("custom");
remove_model_providers = providers.is_empty();
}
if remove_model_providers {
doc.as_table_mut().remove("model_providers");
}
if let Some(model) = model
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
doc["model"] = value(model);
}
write_text(&cfg, &doc.to_string())?;
if let Some(auth_json) = auth_json
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
let parsed: Value = serde_json::from_str(&auth_json).map_err(|e| json_err(&auth, e))?;
if !parsed.is_object() {
return Err(CodexxError::Config(
"auth.json 必须是 JSON object".to_string(),
));
}
write_json(&auth, &parsed)?;
}
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: message.to_string(),
backup_id,
state,
})
}
pub(crate) fn switch_official_provider_with_pre_persist<F>(
config_dir: Option<String>,
pre_persist: F,
) -> Result<ActionResult>
where
F: FnOnce(&Path) -> Result<()>,
{
let codex_dir = resolve_codex_dir(config_dir.clone())?;
pre_persist(&codex_dir)?;
// Switching to official must not overwrite auth.json with a stale cc-switch
// ChatGPT token. Codex desktop/CLI owns the live official login flow; after
// the user logs in, Codex-X should simply refresh and display ~/.codex/auth.json.
apply_official_config(
config_dir,
None,
None,
"switch-official",
"已切换到 OpenAI Official(auth.json 保持当前 live 状态)",
)
}
pub(crate) fn switch_official_provider_inner(config_dir: Option<String>) -> Result<ActionResult> {
switch_official_provider_with_pre_persist(config_dir, persist_detected_live_custom_provider)
}
pub(crate) fn save_official_config_inner(
config_dir: Option<String>,
model: Option<String>,
auth_json: Option<String>,
) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
let backup_id = create_backup(&codex_dir, "save-official")?;
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
if let Some(model) = model
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
doc["model"] = value(model);
write_text(&cfg, &doc.to_string())?;
}
if let Some(auth_json) = auth_json
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
let parsed: Value = serde_json::from_str(&auth_json).map_err(|e| json_err(&auth, e))?;
if !parsed.is_object() {
return Err(CodexxError::Config(
"auth.json 必须是 JSON object".to_string(),
));
}
write_json(&auth, &parsed)?;
}
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: "已保存 OpenAI Official 配置(未切换启用)".to_string(),
backup_id,
state,
})
}
pub(crate) fn save_provider_toml_config_with_pre_persist<F>(
input: ProviderTomlInput,
pre_persist: F,
) -> Result<ActionResult>
where
F: FnOnce(&Path) -> Result<()>,
{
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
ensure_directory(&codex_dir)?;
pre_persist(&codex_dir)?;
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
let backup_id = create_backup(&codex_dir, "save-provider-toml")?;
let config_text = input.config_text.trim_end().to_string();
let mut doc = parse_toml_document(&cfg, &config_text)?;
if string_value(&doc, "model").is_none() {
return Err(CodexxError::Config(
"config.toml 必须包含 model".to_string(),
));
}
let api_key = input
.api_key
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(api_key) = api_key.as_deref() {
set_provider_bearer_token(&mut doc, api_key);
}
write_text(&cfg, &(doc.to_string().trim_end().to_string() + "\n"))?;
if let Some(api_key) = api_key {
let mut auth_value = if auth.exists() {
let text = fs::read_to_string(&auth).map_err(|e| io_err(&auth, e))?;
serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
if !auth_value.is_object() {
auth_value = json!({});
}
auth_value["OPENAI_API_KEY"] = Value::String(api_key);
auth_value["auth_mode"] = Value::String("apikey".to_string());
write_json(&auth, &auth_value)?;
}
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: "已保存供应商 TOML 配置".to_string(),
backup_id,
state,
})
}
pub(crate) fn save_provider_toml_config_inner(input: ProviderTomlInput) -> Result<ActionResult> {
save_provider_toml_config_with_pre_persist(input, persist_detected_live_custom_provider)
}
pub(crate) fn switch_provider_with_pre_persist<F>(
input: ProviderInput,
pre_persist: F,
) -> Result<ActionResult>
where
F: FnOnce(&Path) -> Result<()>,
{
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
ensure_directory(&codex_dir)?;
pre_persist(&codex_dir)?;
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
let backup_id = create_backup(&codex_dir, "switch-provider")?;
let provider_name = input.provider_name.trim();
// Keep the saved provider id only for Codex-X/cc-switch bookkeeping.
// cc-switch writes third-party Codex providers to the live config as
// `model_provider = "custom"` + `[model_providers.custom]`; mirroring
// that behavior avoids Codex CLI/App versions that ignore arbitrary live
// provider ids or keep resolving the previous custom provider.
let live_provider_key = "custom";
let base_url = input.base_url.trim().trim_end_matches('/');
let model = input.model.trim();
if provider_name.is_empty() {
return Err(CodexxError::Config("供应商名称不能为空".to_string()));
}
if base_url.is_empty() {
return Err(CodexxError::Config("base_url 不能为空".to_string()));
}
if model.is_empty() {
return Err(CodexxError::Config("model 不能为空".to_string()));
}
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
doc["model_provider"] = value(live_provider_key);
doc["model"] = value(model);
set_top_level_defaults(&mut doc);
let root = doc.as_table_mut();
let providers = ensure_table(root, "model_providers")?;
providers.remove(live_provider_key);
let provider_table = ensure_table(providers, live_provider_key)?;
provider_table["name"] = value(provider_name);
provider_table["base_url"] = value(base_url);
provider_table["wire_api"] = value(input.wire_api.unwrap_or_else(|| "responses".to_string()));
provider_table["requires_openai_auth"] = value(input.requires_openai_auth.unwrap_or(true));
let api_key = input
.api_key
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(api_key) = api_key.as_deref() {
// New threads reload config.toml, while a running app-server may retain
// the auth.json credential it loaded at startup. The provider-scoped
// token makes provider switches effective without restarting Codex.
set_provider_bearer_token(&mut doc, api_key);
}
write_text(&cfg, &doc.to_string())?;
if let Some(api_key) = api_key {
let auth_value = json!({
"OPENAI_API_KEY": api_key,
"auth_mode": "apikey",
});
write_json(&auth, &auth_value)?;
}
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: format!("已切换到 {provider_name} / {model}"),
backup_id,
state,
})
}
pub(crate) fn switch_provider_inner(input: ProviderInput) -> Result<ActionResult> {
switch_provider_with_pre_persist(input, persist_detected_live_custom_provider)
}
@@ -0,0 +1,49 @@
mod ccswitch;
mod connection;
mod live;
mod store;
use crate::error::Result;
use rusqlite::Connection;
#[cfg(test)]
pub(crate) use ccswitch::{
build_ccswitch_codex_provider, codex_sections_from_config, is_official_ccswitch_row,
read_ccswitch_codex_rows, CcSwitchCodexRow,
};
pub(crate) use ccswitch::{
import_ccswitch_codex_providers_inner, read_ccswitch_official_auth_inner, ImportResult,
OfficialAuthCandidate,
};
#[cfg(test)]
pub(crate) use connection::provider_status_result;
pub(crate) use connection::{
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
ProviderModelsResult,
};
#[cfg(test)]
pub(crate) use live::{
detected_live_custom_provider, save_provider_toml_config_with_pre_persist,
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
};
pub(crate) use live::{
save_official_config_inner, save_provider_toml_config_inner, switch_official_provider_inner,
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
};
#[cfg(test)]
pub(crate) use store::{
canonical_provider_base_url, merge_duplicate_provider_identities, provider_by_id_on_connection,
provider_identity, save_manual_provider_on_connection,
};
pub(crate) use store::{
custom_provider_id, delete_provider_inner, experimental_bearer_token_from_doc,
list_saved_providers_inner, list_saved_providers_on_connection, normalize_saved_provider,
reserved_codex_provider_id, save_detected_provider_inner, save_provider_inner,
upsert_provider_on_connection, ProviderUpsertKind, ProviderUpsertMode, SavedProvider,
};
pub(crate) fn open_store() -> Result<Connection> {
let mut conn = crate::app_db::open()?;
store::merge_duplicate_provider_identities(&mut conn)?;
Ok(conn)
}
@@ -0,0 +1,548 @@
use super::open_store as open_db;
use crate::error::{CodexxError, Result};
use crate::{now_rfc3339, sanitize_id};
use rusqlite::{params, Connection, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use toml_edit::DocumentMut;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedProvider {
pub(crate) id: String,
pub(crate) provider_name: String,
pub(crate) base_url: String,
pub(crate) model: String,
pub(crate) api_key: Option<String>,
pub(crate) toml_config: Option<String>,
pub(crate) wire_api: String,
pub(crate) requires_openai_auth: bool,
}
#[derive(Debug, Clone)]
struct StoredProvider {
provider: SavedProvider,
created_at: String,
updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum ProviderIdentity {
Credential([u8; 32]),
Unauthenticated { base_url: String, name: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProviderUpsertMode {
Manual,
Imported,
Detected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProviderUpsertKind {
Added,
Updated,
Merged,
}
#[derive(Debug, Clone)]
pub(crate) struct ProviderUpsertResult {
pub(crate) provider: SavedProvider,
pub(crate) kind: ProviderUpsertKind,
}
pub(crate) fn canonical_provider_base_url(input: &str) -> String {
let trimmed = input.trim();
if trimmed.is_empty() {
return String::new();
}
if let Ok(parsed) = ureq::get(trimmed).request_url() {
let url = parsed.as_url();
if let Some(host) = url.host_str() {
let mut canonical = format!("{}://", url.scheme().to_ascii_lowercase());
if !url.username().is_empty() {
canonical.push_str(url.username());
if let Some(password) = url.password() {
canonical.push(':');
canonical.push_str(password);
}
canonical.push('@');
}
if host.contains(':') && !host.starts_with('[') {
canonical.push('[');
canonical.push_str(&host.to_ascii_lowercase());
canonical.push(']');
} else {
canonical.push_str(&host.to_ascii_lowercase());
}
if let Some(port) = url.port() {
canonical.push(':');
canonical.push_str(&port.to_string());
}
let path = url.path().trim_end_matches('/');
if !path.is_empty() {
canonical.push_str(path);
}
if let Some(query) = url.query() {
canonical.push('?');
canonical.push_str(query);
}
return canonical;
}
}
trimmed.trim_end_matches('/').to_string()
}
pub(crate) fn provider_identity(provider: &SavedProvider) -> Option<ProviderIdentity> {
use sha2::{Digest, Sha256};
let base_url = canonical_provider_base_url(&provider.base_url);
if base_url.is_empty() {
return None;
}
let explicit_api_key = provider
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let toml_api_key = provider.toml_config.as_deref().and_then(|text| {
let doc = text.parse::<DocumentMut>().ok()?;
let provider_id = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|value| !value.is_empty());
experimental_bearer_token_from_doc(&doc, provider_id)
});
if let Some(api_key) = explicit_api_key.or(toml_api_key.as_deref()) {
// Hash the complete endpoint/credential tuple so neither the key nor a
// reusable key-only fingerprint is persisted, logged, or sent to the UI.
let mut hasher = Sha256::new();
hasher.update(b"codex-x/provider-identity/v1\0");
hasher.update(base_url.as_bytes());
hasher.update(b"\0");
hasher.update(api_key.as_bytes());
let digest: [u8; 32] = hasher.finalize().into();
return Some(ProviderIdentity::Credential(digest));
}
let name = provider
.provider_name
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
(!name.is_empty()).then_some(ProviderIdentity::Unauthenticated { base_url, name })
}
fn saved_provider_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SavedProvider> {
Ok(SavedProvider {
id: row.get(0)?,
provider_name: row.get(1)?,
base_url: row.get(2)?,
model: row.get(3)?,
api_key: row.get(4)?,
toml_config: row.get(5)?,
wire_api: row.get(6)?,
requires_openai_auth: row.get::<_, i64>(7)? != 0,
})
}
fn stored_providers_on_connection(conn: &Connection) -> Result<Vec<StoredProvider>> {
let mut stmt = conn
.prepare(
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
requires_openai_auth, created_at, updated_at
FROM providers
ORDER BY created_at ASC, updated_at ASC, id ASC",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok(StoredProvider {
provider: saved_provider_from_row(row)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
})
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut providers = Vec::new();
for row in rows {
providers.push(row.map_err(|e| CodexxError::Database(e.to_string()))?);
}
Ok(providers)
}
pub(crate) fn list_saved_providers_on_connection(conn: &Connection) -> Result<Vec<SavedProvider>> {
Ok(stored_providers_on_connection(conn)?
.into_iter()
.map(|stored| stored.provider)
.collect())
}
pub(crate) fn list_saved_providers_inner() -> Result<Vec<SavedProvider>> {
let conn = open_db()?;
list_saved_providers_on_connection(&conn)
}
pub(crate) fn provider_by_id_on_connection(
conn: &Connection,
id: &str,
) -> Result<Option<SavedProvider>> {
let mut stmt = conn
.prepare(
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
requires_openai_auth
FROM providers WHERE id = ?1 LIMIT 1",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
stmt.query_row([id], saved_provider_from_row)
.map(Some)
.or_else(|error| {
if matches!(error, rusqlite::Error::QueryReturnedNoRows) {
Ok(None)
} else {
Err(error)
}
})
.map_err(|e| CodexxError::Database(e.to_string()))
}
fn write_provider_on_connection(conn: &Connection, provider: &SavedProvider) -> Result<()> {
let now = now_rfc3339();
conn.execute(
"INSERT INTO providers
(id, provider_name, base_url, model, api_key, toml_config, wire_api, requires_openai_auth, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
ON CONFLICT(id) DO UPDATE SET
provider_name = excluded.provider_name,
base_url = excluded.base_url,
model = excluded.model,
api_key = excluded.api_key,
toml_config = excluded.toml_config,
wire_api = excluded.wire_api,
requires_openai_auth = excluded.requires_openai_auth,
updated_at = excluded.updated_at",
params![
provider.id,
provider.provider_name,
provider.base_url,
provider.model,
provider.api_key,
provider.toml_config,
provider.wire_api,
if provider.requires_openai_auth { 1 } else { 0 },
now,
],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
fn unique_provider_id_on_connection(conn: &Connection, preferred: &str) -> Result<String> {
if provider_by_id_on_connection(conn, preferred)?.is_none() {
return Ok(preferred.to_string());
}
let mut index = 2usize;
loop {
let candidate = format!("{preferred}-{index}");
if provider_by_id_on_connection(conn, &candidate)?.is_none() {
return Ok(candidate);
}
index += 1;
}
}
fn preserve_existing_provider_config(
mut incoming: SavedProvider,
existing: &SavedProvider,
) -> SavedProvider {
incoming.id = existing.id.clone();
if !existing.provider_name.trim().is_empty() {
incoming.provider_name = existing.provider_name.clone();
}
if !existing.base_url.trim().is_empty() {
incoming.base_url = existing.base_url.clone();
}
if !existing.model.trim().is_empty() {
incoming.model = existing.model.clone();
}
if existing
.api_key
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
incoming.api_key = existing.api_key.clone();
}
if existing
.toml_config
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
incoming.toml_config = existing.toml_config.clone();
}
if !existing.wire_api.trim().is_empty() {
incoming.wire_api = existing.wire_api.clone();
}
incoming.requires_openai_auth = existing.requires_openai_auth;
incoming
}
pub(crate) fn upsert_provider_on_connection(
conn: &Connection,
mut provider: SavedProvider,
mode: ProviderUpsertMode,
) -> Result<ProviderUpsertResult> {
let requested_id = provider.id.clone();
let identity = provider_identity(&provider);
let stored = stored_providers_on_connection(conn)?;
let identity_match = identity.as_ref().and_then(|identity| {
stored
.iter()
.find(|candidate| provider_identity(&candidate.provider).as_ref() == Some(identity))
});
let exact_id_match = stored
.iter()
.find(|candidate| candidate.provider.id == requested_id);
let target = match mode {
ProviderUpsertMode::Manual => exact_id_match.or(identity_match),
ProviderUpsertMode::Imported | ProviderUpsertMode::Detected => identity_match,
};
let kind = if let Some(target) = target {
let existing = &target.provider;
let same_id = existing.id == requested_id;
provider.id = existing.id.clone();
if mode != ProviderUpsertMode::Manual {
provider = preserve_existing_provider_config(provider, existing);
}
if same_id {
ProviderUpsertKind::Updated
} else {
ProviderUpsertKind::Merged
}
} else {
if exact_id_match.is_some() {
provider.id = unique_provider_id_on_connection(conn, &provider.id)?;
}
ProviderUpsertKind::Added
};
write_provider_on_connection(conn, &provider)?;
let provider = provider_by_id_on_connection(conn, &provider.id)?
.ok_or_else(|| CodexxError::Database("provider saved but not found".to_string()))?;
Ok(ProviderUpsertResult { provider, kind })
}
pub(crate) fn merge_duplicate_provider_identities(conn: &mut Connection) -> Result<usize> {
let rows = stored_providers_on_connection(conn)?;
let mut groups: HashMap<ProviderIdentity, Vec<StoredProvider>> = HashMap::new();
for row in rows {
if let Some(identity @ ProviderIdentity::Credential(_)) = provider_identity(&row.provider) {
groups.entry(identity).or_default().push(row);
}
}
let duplicate_groups = groups
.into_values()
.filter(|group| group.len() > 1)
.collect::<Vec<_>>();
if duplicate_groups.is_empty() {
return Ok(0);
}
let transaction = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut merged = 0usize;
for mut group in duplicate_groups {
group.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.provider.id.cmp(&right.provider.id))
});
let mut survivor = group[0].clone();
for duplicate in group.iter().skip(1) {
if survivor.provider.provider_name.trim().is_empty()
&& !duplicate.provider.provider_name.trim().is_empty()
{
survivor.provider.provider_name = duplicate.provider.provider_name.clone();
}
if survivor.provider.model.trim().is_empty()
&& !duplicate.provider.model.trim().is_empty()
{
survivor.provider.model = duplicate.provider.model.clone();
}
if survivor
.provider
.toml_config
.as_deref()
.is_none_or(|value| value.trim().is_empty())
&& duplicate
.provider
.toml_config
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
survivor.provider.toml_config = duplicate.provider.toml_config.clone();
}
if duplicate.updated_at > survivor.updated_at {
survivor.updated_at = duplicate.updated_at.clone();
}
}
survivor.provider.base_url = canonical_provider_base_url(&survivor.provider.base_url);
survivor.provider.api_key = survivor
.provider
.api_key
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
transaction
.execute(
"UPDATE providers SET provider_name = ?2, base_url = ?3, model = ?4,
api_key = ?5, toml_config = ?6, wire_api = ?7,
requires_openai_auth = ?8, updated_at = ?9
WHERE id = ?1",
params![
survivor.provider.id,
survivor.provider.provider_name,
survivor.provider.base_url,
survivor.provider.model,
survivor.provider.api_key,
survivor.provider.toml_config,
survivor.provider.wire_api,
if survivor.provider.requires_openai_auth {
1
} else {
0
},
survivor.updated_at,
],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
for duplicate in group.iter().skip(1) {
transaction
.execute(
"DELETE FROM providers WHERE id = ?1",
[&duplicate.provider.id],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
merged += 1;
}
}
transaction
.commit()
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(merged)
}
pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedProvider> {
let raw_id = provider.id.trim();
if raw_id.is_empty() {
return Err(CodexxError::Config("provider id 不能为空".to_string()));
}
let normalized = SavedProvider {
id: custom_provider_id(raw_id),
provider_name: provider.provider_name.trim().to_string(),
base_url: canonical_provider_base_url(&provider.base_url),
model: provider.model.trim().to_string(),
api_key: provider
.api_key
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
toml_config: provider
.toml_config
.map(|value| value.trim_end().to_string())
.filter(|value| !value.trim().is_empty()),
wire_api: if provider.wire_api.trim().is_empty() {
"responses".to_string()
} else {
provider.wire_api.trim().to_string()
},
requires_openai_auth: provider.requires_openai_auth,
};
if normalized.provider_name.is_empty() {
return Err(CodexxError::Config("供应商名称不能为空".to_string()));
}
if normalized.base_url.is_empty() {
return Err(CodexxError::Config("base_url 不能为空".to_string()));
}
if normalized.model.is_empty() {
return Err(CodexxError::Config("model 不能为空".to_string()));
}
Ok(normalized)
}
pub(crate) fn save_manual_provider_on_connection(
conn: &Connection,
provider: SavedProvider,
) -> Result<SavedProvider> {
let requested_id = provider.id.trim().to_string();
let provider = normalize_saved_provider(provider)?;
if requested_id != provider.id && provider_by_id_on_connection(conn, &provider.id)?.is_some() {
return Err(CodexxError::Config(format!(
"供应商 ID {} 规范化后与现有供应商冲突,请更换名称或 ID",
requested_id
)));
}
Ok(upsert_provider_on_connection(conn, provider, ProviderUpsertMode::Manual)?.provider)
}
pub(crate) fn save_provider_inner(provider: SavedProvider) -> Result<SavedProvider> {
let conn = open_db()?;
save_manual_provider_on_connection(&conn, provider)
}
pub(crate) fn save_detected_provider_inner(provider: SavedProvider) -> Result<SavedProvider> {
let provider = normalize_saved_provider(provider)?;
let conn = open_db()?;
Ok(upsert_provider_on_connection(&conn, provider, ProviderUpsertMode::Detected)?.provider)
}
pub(crate) fn delete_provider_inner(id: &str) -> Result<()> {
let conn = open_db()?;
conn.execute("DELETE FROM providers WHERE id = ?1", params![id])
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
pub(crate) fn reserved_codex_provider_id(id: &str) -> bool {
matches!(
id.trim().to_ascii_lowercase().as_str(),
"openai" | "amazon-bedrock" | "ollama" | "lmstudio" | "oss"
)
}
pub(crate) fn custom_provider_id(input: &str) -> String {
let id = sanitize_id(input);
if reserved_codex_provider_id(&id) {
format!("{id}-custom")
} else {
id
}
}
pub(crate) fn experimental_bearer_token_from_doc(
doc: &DocumentMut,
provider_id: Option<&str>,
) -> Option<String> {
let token_from_table = provider_id.and_then(|id| {
doc.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|providers| providers.get(id))
.and_then(|item| item.as_table())
.and_then(|table| table.get("experimental_bearer_token"))
.and_then(|item| item.as_str())
});
token_from_table
.or_else(|| {
doc.get("experimental_bearer_token")
.and_then(|item| item.as_str())
})
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
}
+306
View File
@@ -0,0 +1,306 @@
use crate::error::{CodexxError, Result};
use std::sync::OnceLock;
use std::time::Duration;
const REMOTE_USER_AGENT: &str = "Codex-X";
const REMOTE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy)]
pub(crate) struct RemoteSource<'a> {
pub(crate) key: &'static str,
pub(crate) url: &'a str,
pub(crate) accept: Option<&'static str>,
}
impl<'a> RemoteSource<'a> {
pub(crate) const fn new(key: &'static str, url: &'a str, accept: Option<&'static str>) -> Self {
Self { key, url, accept }
}
}
pub(crate) fn ensure_crypto_provider() {
static CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new();
CRYPTO_PROVIDER.get_or_init(|| {
// Another TLS client may already have initialized the process-wide provider.
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
pub(crate) fn remote_client() -> Result<reqwest::blocking::Client> {
ensure_crypto_provider();
reqwest::blocking::Client::builder()
.timeout(REMOTE_TIMEOUT)
.user_agent(REMOTE_USER_AGENT)
.build()
.map_err(|_| CodexxError::Config("网络客户端初始化失败".to_string()))
}
pub(crate) fn remote_request_error(
source: &RemoteSource<'_>,
error: &reqwest::Error,
) -> CodexxError {
let reason = if error.is_timeout() {
"请求超时"
} else if error.is_connect() {
"网络连接失败"
} else {
"网络请求失败"
};
CodexxError::Config(format!("{} {reason}", source.key))
}
fn fetch_remote_text(source: &RemoteSource<'_>) -> Result<String> {
// A fresh client picks up proxy changes made while the app is running. Reqwest's
// system proxy resolver also handles proxy environment variables and NO_PROXY.
let client = remote_client()?;
let mut request = client.get(source.url);
if let Some(accept) = source.accept {
request = request.header(reqwest::header::ACCEPT, accept);
}
let response = request
.send()
.map_err(|error| remote_request_error(source, &error))?;
let status = response.status();
if !status.is_success() {
return Err(CodexxError::Config(format!(
"{} 请求失败(HTTP {})",
source.key,
status.as_u16()
)));
}
response
.text()
.map_err(|_| CodexxError::Config(format!("{} 响应读取失败", source.key)))
}
pub(crate) fn fetch_first_valid<T, Parse>(sources: &[RemoteSource<'_>], parse: Parse) -> Result<T>
where
Parse: FnMut(&RemoteSource<'_>, &str) -> Result<T>,
{
fetch_first_valid_with(sources, fetch_remote_text, parse)
}
pub(crate) fn fetch_first_valid_with<T, Fetch, Parse>(
sources: &[RemoteSource<'_>],
mut fetch: Fetch,
mut parse: Parse,
) -> Result<T>
where
Fetch: FnMut(&RemoteSource<'_>) -> Result<String>,
Parse: FnMut(&RemoteSource<'_>, &str) -> Result<T>,
{
let mut errors = Vec::new();
for source in sources {
match fetch(source) {
Ok(body) if body.trim().is_empty() => {
errors.push(format!("{} 返回空内容", source.key));
}
Ok(body) => match parse(source, &body) {
Ok(value) => return Ok(value),
Err(error) => errors.push(format!("{}: {error}", source.key)),
},
Err(error) => errors.push(format!("{}: {error}", source.key)),
}
}
Err(CodexxError::Config(format!(
"远程内容获取失败{}",
if errors.is_empty() {
String::new()
} else {
format!(":{}", errors.join(";"))
}
)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::process::Command;
use std::thread;
use std::time::Instant;
const SOURCES: [RemoteSource<'static>; 2] = [
RemoteSource::new("cdn", "https://cdn.example.test", None),
RemoteSource::new("origin", "https://origin.example.test", None),
];
const PROXY_TEST_URL: &str = "CODEXX_REMOTE_PROXY_TEST_URL";
const PROXY_TEST_BODY: &str = "CODEXX_REMOTE_PROXY_TEST_BODY";
fn clear_proxy_environment(command: &mut Command) {
for name in [
"ALL_PROXY",
"all_proxy",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"NO_PROXY",
"no_proxy",
"REQUEST_METHOD",
] {
command.env_remove(name);
}
}
fn serve_once(listener: TcpListener, body: &'static str) -> String {
listener
.set_nonblocking(true)
.expect("set listener nonblocking");
let started = Instant::now();
let (mut stream, _) = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
started.elapsed() < Duration::from_secs(8),
"proxy test connection timed out"
);
thread::sleep(Duration::from_millis(20));
}
Err(error) => panic!("accept proxy test connection: {error}"),
}
};
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set read timeout");
let mut request = [0_u8; 8192];
let read = stream.read(&mut request).expect("read proxy request");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("write proxy response");
String::from_utf8_lossy(&request[..read]).into_owned()
}
fn run_proxy_test_child(url: &str, body: &str, configure: impl FnOnce(&mut Command)) {
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
command.args([
"--exact",
"remote::tests::proxy_environment_child",
"--nocapture",
]);
clear_proxy_environment(&mut command);
command.env(PROXY_TEST_URL, url).env(PROXY_TEST_BODY, body);
configure(&mut command);
let output = command.output().expect("run proxy test child");
assert!(
output.status.success(),
"proxy test child failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn proxy_environment_child() {
let Ok(url) = std::env::var(PROXY_TEST_URL) else {
return;
};
let expected = std::env::var(PROXY_TEST_BODY).expect("proxy test body");
let source = RemoteSource::new("proxy test", &url, None);
assert_eq!(
fetch_remote_text(&source).expect("fetch test response"),
expected
);
}
#[test]
fn successful_source_stops_fallback_chain() {
let mut calls = Vec::new();
let value = fetch_first_valid_with(
&SOURCES,
|source| {
calls.push(source.key);
Ok("valid".to_string())
},
|_, body| Ok(body.to_string()),
)
.expect("first source succeeds");
assert_eq!(value, "valid");
assert_eq!(calls, ["cdn"]);
}
#[test]
fn empty_and_unparseable_responses_try_later_sources() {
let mut calls = Vec::new();
let value = fetch_first_valid_with(
&SOURCES,
|source| {
calls.push(source.key);
if source.key == "cdn" {
Ok(" ".to_string())
} else {
Ok("42".to_string())
}
},
|_, body| {
body.parse::<u32>()
.map_err(|error| CodexxError::Config(error.to_string()))
},
)
.expect("origin succeeds after empty CDN response");
assert_eq!(value, 42);
assert_eq!(calls, ["cdn", "origin"]);
}
#[test]
fn remote_client_uses_lowercase_http_proxy() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test proxy");
let proxy_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || serve_once(listener, "through proxy"));
run_proxy_test_child(
"http://remote.invalid/template.md",
"through proxy",
|command| {
command.env("http_proxy", proxy_url);
},
);
let request = server.join().expect("join proxy server");
assert!(request.starts_with("GET http://remote.invalid/template.md "));
}
#[test]
fn remote_client_respects_lowercase_no_proxy() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test origin");
let origin_url = format!("http://{}/direct.md", listener.local_addr().unwrap());
let server = thread::spawn(move || serve_once(listener, "direct response"));
run_proxy_test_child(&origin_url, "direct response", |command| {
command
.env("HTTP_PROXY", "http://127.0.0.1:9")
.env("no_proxy", "127.0.0.1");
});
let request = server.join().expect("join origin server");
assert!(request.starts_with("GET /direct.md "));
}
#[test]
fn connection_errors_do_not_expose_request_or_proxy_details() {
remote_client().expect("initialize TLS provider");
let client = reqwest::blocking::Client::builder()
.no_proxy()
.timeout(Duration::from_millis(200))
.build()
.expect("build direct client");
let error = client
.get("http://127.0.0.1:0/private")
.send()
.expect_err("reserved port should fail");
let message = remote_request_error(&SOURCES[0], &error).to_string();
assert!(message.contains("cdn"));
assert!(!message.contains("127.0.0.1"));
assert!(!message.contains("private"));
assert!(!message.contains('@'));
}
}
@@ -0,0 +1,251 @@
use crate::error::Result;
use crate::platform;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
#[derive(Debug, Default)]
pub(super) struct OfficialSessionDeleteOutcome {
pub(super) deleted_ids: HashSet<String>,
pub(super) completed_roots: HashSet<String>,
pub(super) failed_roots: Vec<(String, String)>,
}
#[derive(Debug)]
enum AppServerDeleteAttempt {
Success(OfficialSessionDeleteOutcome),
Unsupported(String),
}
fn send_app_server_message(
stdin: &mut impl Write,
value: &Value,
) -> std::result::Result<(), String> {
let mut line = serde_json::to_vec(value).map_err(|error| error.to_string())?;
line.push(b'\n');
stdin.write_all(&line).map_err(|error| error.to_string())?;
stdin.flush().map_err(|error| error.to_string())
}
fn recv_app_server_message(
receiver: &mpsc::Receiver<std::io::Result<String>>,
deadline: Instant,
) -> std::result::Result<Value, String> {
loop {
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(|| "Codex App Server 响应超时".to_string())?;
let line = receiver
.recv_timeout(remaining)
.map_err(|error| format!("Codex App Server 响应失败: {error}"))?
.map_err(|error| format!("读取 Codex App Server 输出失败: {error}"))?;
if line.trim().is_empty() {
continue;
}
return serde_json::from_str(&line)
.map_err(|error| format!("解析 Codex App Server 输出失败: {error}"));
}
}
fn app_server_error(value: &Value) -> Option<(i64, String)> {
let error = value.get("error")?;
Some((
error
.get("code")
.and_then(Value::as_i64)
.unwrap_or_default(),
error
.get("message")
.and_then(Value::as_str)
.unwrap_or("Codex App Server 返回未知错误")
.to_string(),
))
}
fn stop_app_server_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn run_app_server_delete_attempt(
mut child: Child,
codex_dir: &Path,
roots: &[String],
) -> AppServerDeleteAttempt {
let Some(mut stdin) = child.stdin.take() else {
stop_app_server_child(&mut child);
return AppServerDeleteAttempt::Unsupported("Codex App Server stdin 不可用".to_string());
};
let Some(stdout) = child.stdout.take() else {
stop_app_server_child(&mut child);
return AppServerDeleteAttempt::Unsupported("Codex App Server stdout 不可用".to_string());
};
let (sender, receiver) = mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
if sender.send(line).is_err() {
break;
}
}
});
let result = (|| -> AppServerDeleteAttempt {
let initialize_id = 1i64;
let initialize = json!({
"id": initialize_id,
"method": "initialize",
"params": {
"clientInfo": {
"name": "codex_x",
"title": "Codex-X",
"version": env!("CARGO_PKG_VERSION")
},
"capabilities": null
}
});
if let Err(error) = send_app_server_message(&mut stdin, &initialize) {
return AppServerDeleteAttempt::Unsupported(error);
}
let initialize_deadline = Instant::now() + Duration::from_secs(8);
loop {
let message = match recv_app_server_message(&receiver, initialize_deadline) {
Ok(message) => message,
Err(error) => return AppServerDeleteAttempt::Unsupported(error),
};
if message.get("id").and_then(Value::as_i64) != Some(initialize_id) {
continue;
}
if let Some((_, message)) = app_server_error(&message) {
return AppServerDeleteAttempt::Unsupported(message);
}
let Some(server_home) = message
.get("result")
.and_then(|value| value.get("codexHome"))
.and_then(Value::as_str)
else {
return AppServerDeleteAttempt::Unsupported(
"Codex App Server 未返回 CODEX_HOME".to_string(),
);
};
let requested_home = codex_dir
.canonicalize()
.unwrap_or_else(|_| codex_dir.to_path_buf());
let returned_home = PathBuf::from(server_home)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(server_home));
if requested_home != returned_home {
return AppServerDeleteAttempt::Unsupported(format!(
"Codex App Server 使用了不同的 CODEX_HOME: {}",
returned_home.display()
));
}
break;
}
if let Err(error) = send_app_server_message(&mut stdin, &json!({"method": "initialized"})) {
return AppServerDeleteAttempt::Unsupported(error);
}
let mut outcome = OfficialSessionDeleteOutcome::default();
'delete_roots: for (index, root) in roots.iter().enumerate() {
let request_id = 1000 + index as i64;
if let Err(error) = send_app_server_message(
&mut stdin,
&json!({
"id": request_id,
"method": "thread/delete",
"params": { "threadId": root }
}),
) {
for pending in &roots[index..] {
outcome.failed_roots.push((pending.clone(), error.clone()));
}
break 'delete_roots;
}
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let message = match recv_app_server_message(&receiver, deadline) {
Ok(message) => message,
Err(error) => {
for pending in &roots[index..] {
outcome.failed_roots.push((pending.clone(), error.clone()));
}
break 'delete_roots;
}
};
if message.get("method").and_then(Value::as_str) == Some("thread/deleted") {
if let Some(id) = message
.get("params")
.and_then(|value| value.get("threadId"))
.and_then(Value::as_str)
{
outcome.deleted_ids.insert(id.to_string());
}
}
if message.get("id").and_then(Value::as_i64) == Some(request_id) {
if let Some((code, error)) = app_server_error(&message) {
let lower = error.to_ascii_lowercase();
if code == -32601 {
return AppServerDeleteAttempt::Unsupported(error);
}
if code == -32600
&& (lower.contains("no rollout")
|| lower.contains("not found")
|| lower.contains("does not exist"))
{
outcome.completed_roots.insert(root.clone());
break;
}
outcome.failed_roots.push((root.clone(), error));
break;
}
outcome.completed_roots.insert(root.clone());
break;
}
}
}
AppServerDeleteAttempt::Success(outcome)
})();
drop(stdin);
stop_app_server_child(&mut child);
let _ = reader.join();
result
}
pub(super) fn delete_sessions_via_codex_app_server(
codex_dir: &Path,
roots: &[String],
) -> Result<Option<OfficialSessionDeleteOutcome>> {
let mut unsupported_messages = Vec::new();
for program in platform::codex_executable_candidates() {
let is_bare_command = program.components().count() == 1;
if !is_bare_command && !program.is_file() {
continue;
}
let mut command = platform::program_command(&program, &["app-server", "--stdio"]);
command
.env("CODEX_HOME", codex_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let child = match command.spawn() {
Ok(child) => child,
Err(error) => {
unsupported_messages.push(format!("{}: {error}", program.display()));
continue;
}
};
match run_app_server_delete_attempt(child, codex_dir, roots) {
AppServerDeleteAttempt::Success(outcome) => return Ok(Some(outcome)),
AppServerDeleteAttempt::Unsupported(message) => {
unsupported_messages.push(format!("{}: {message}", program.display()));
}
}
}
let _ = unsupported_messages;
Ok(None)
}
@@ -0,0 +1,235 @@
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err, write_json};
use chrono::Local;
use rusqlite::{Connection, OpenFlags};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub(super) struct BackupSnapshot {
live_path: PathBuf,
backup_path: PathBuf,
existed: bool,
}
#[derive(Debug)]
pub(super) struct ProviderSyncBackup {
pub(super) dir: PathBuf,
}
pub(crate) fn provider_sync_backup_root(codex_dir: &Path) -> PathBuf {
codex_dir.join("backups_state").join("provider-sync")
}
fn backup_relative_path(codex_dir: &Path, source: &Path) -> PathBuf {
match source.strip_prefix(codex_dir) {
Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
_ => {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(source.to_string_lossy().as_bytes());
let key = digest[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
PathBuf::from("external").join(key).join(
source
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("file")),
)
}
}
}
fn backup_target_path(codex_dir: &Path, backup_dir: &Path, source: &Path) -> Result<PathBuf> {
let target = backup_dir.join(backup_relative_path(codex_dir, source));
if target == source {
return Err(CodexxError::Config(format!(
"拒绝将备份写回源文件: {}",
source.display()
)));
}
Ok(target)
}
fn copy_file_to_backup(
codex_dir: &Path,
backup_dir: &Path,
source: &Path,
) -> Result<BackupSnapshot> {
let target = backup_target_path(codex_dir, backup_dir, source)?;
let existed = source.exists();
if existed {
if let Some(parent) = target.parent() {
ensure_directory(parent)?;
}
fs::copy(source, &target).map_err(|e| io_err(&target, e))?;
}
Ok(BackupSnapshot {
live_path: source.to_path_buf(),
backup_path: target,
existed,
})
}
pub(crate) fn backup_sqlite_to_backup(
codex_dir: &Path,
backup_dir: &Path,
source: &Path,
) -> Result<()> {
use rusqlite::backup::{Backup, StepResult};
if !source.exists() {
return Err(CodexxError::Database(format!(
"SQLite 快照源不存在: {}",
source.display()
)));
}
let target = backup_target_path(codex_dir, backup_dir, source)?;
if let Some(parent) = target.parent() {
ensure_directory(parent)?;
}
let from = Connection::open_with_flags(
source,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
CodexxError::Database(format!("打开 SQLite 备份源失败 {}: {e}", source.display()))
})?;
from.busy_timeout(Duration::from_secs(5))
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut to = Connection::open(&target).map_err(|e| {
CodexxError::Database(format!("创建 SQLite 备份失败 {}: {e}", target.display()))
})?;
let deadline = Instant::now() + Duration::from_secs(15);
{
let backup = Backup::new(&from, &mut to)
.map_err(|e| CodexxError::Database(format!("初始化 SQLite 快照失败: {e}")))?;
loop {
if Instant::now() >= deadline {
return Err(CodexxError::Database(format!(
"SQLite 快照超时: {}",
source.display()
)));
}
match backup
.step(128)
.map_err(|e| CodexxError::Database(format!("写入 SQLite 快照失败: {e}")))?
{
StepResult::Done => break,
StepResult::More => {}
StepResult::Busy | StepResult::Locked => {
std::thread::sleep(Duration::from_millis(50));
}
_ => {}
}
}
}
let quick_check: String = to
.query_row("PRAGMA quick_check", [], |row| row.get(0))
.map_err(|e| CodexxError::Database(format!("校验 SQLite 备份失败: {e}")))?;
if quick_check != "ok" {
return Err(CodexxError::Database(format!(
"SQLite 备份校验失败 {}: {quick_check}",
target.display()
)));
}
if !target.is_file() {
return Err(CodexxError::Database(format!(
"SQLite 快照未生成: {}",
target.display()
)));
}
Ok(())
}
pub(crate) fn prune_provider_sync_backups(codex_dir: &Path) -> Result<()> {
let root = provider_sync_backup_root(codex_dir);
if !root.exists() {
return Ok(());
}
let mut dirs = Vec::new();
for entry in fs::read_dir(&root).map_err(|e| io_err(&root, e))? {
let entry = entry.map_err(|e| io_err(&root, e))?;
let path = entry.path();
let metadata_path = path.join("metadata.json");
if !path.is_dir() || !metadata_path.exists() {
continue;
}
let is_v2_provider_sync_backup = fs::read_to_string(&metadata_path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.is_some_and(|metadata| {
metadata.get("managedBy").and_then(Value::as_str)
== Some("Codex-X provider sync v2")
});
if is_v2_provider_sync_backup {
dirs.push(path);
}
}
dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
for path in dirs.into_iter().skip(5) {
let _ = fs::remove_dir_all(path);
}
Ok(())
}
pub(super) fn create_provider_sync_backup(
codex_dir: &Path,
target_provider: &str,
changed_rollouts: &[PathBuf],
sqlite_paths: &[PathBuf],
) -> Result<ProviderSyncBackup> {
let root = provider_sync_backup_root(codex_dir);
ensure_directory(&root)?;
let mut backup_dir = root.join(Local::now().format("%Y%m%d%H%M%S").to_string());
let mut suffix = 0;
while backup_dir.exists() {
suffix += 1;
backup_dir = root.join(format!("{}-{suffix}", Local::now().format("%Y%m%d%H%M%S")));
}
ensure_directory(&backup_dir)?;
let mut snapshots = Vec::new();
for path in sqlite_paths {
backup_sqlite_to_backup(codex_dir, &backup_dir, path)?;
snapshots.push(BackupSnapshot {
live_path: path.clone(),
backup_path: backup_target_path(codex_dir, &backup_dir, path)?,
existed: path.exists(),
});
}
for name in [
"config.toml",
".codex-global-state.json",
".codex-global-state.json.bak",
] {
snapshots.push(copy_file_to_backup(
codex_dir,
&backup_dir,
&codex_dir.join(name),
)?);
}
for path in changed_rollouts {
snapshots.push(copy_file_to_backup(codex_dir, &backup_dir, path)?);
}
write_json(
&backup_dir.join("metadata.json"),
&json!({
"version": 1,
"namespace": "provider-sync",
"managedBy": "Codex-X provider sync v2",
"codexHome": codex_dir.display().to_string(),
"targetProvider": target_provider,
"createdAt": Local::now().to_rfc3339(),
"changedRolloutFiles": changed_rollouts.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
"snapshots": snapshots.iter().map(|snapshot| json!({
"livePath": snapshot.live_path.display().to_string(),
"backupPath": snapshot.backup_path.display().to_string(),
"existed": snapshot.existed,
})).collect::<Vec<_>>(),
}),
)?;
Ok(ProviderSyncBackup { dir: backup_dir })
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,338 @@
use crate::error::{CodexxError, Result};
use crate::file_io::{atomic_write, io_err, json_err, write_text};
use serde_json::{json, Map, Value};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub(super) struct GlobalStateWrite {
pub(super) path: std::path::PathBuf,
pub(super) original_bytes: Option<Vec<u8>>,
pub(super) written_bytes: Vec<u8>,
}
pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with(r"\\?\unc\") {
return Some(format!(r"\\{}", trimmed[8..].replace('/', r"\")));
}
if let Some(stripped) = trimmed.strip_prefix(r"\\?\") {
return Some(stripped.replace('\\', "/"));
}
Some(trimmed.to_string())
}
fn load_global_state(path: &Path) -> Result<Map<String, Value>> {
if !path.exists() {
return Ok(Map::new());
}
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
let value = serde_json::from_str::<Value>(&text).map_err(|error| json_err(path, error))?;
Ok(value.as_object().cloned().unwrap_or_default())
}
pub(super) fn projectless_thread_ids(path: &Path) -> Result<HashSet<String>> {
let state = load_global_state(path)?;
Ok(state
.get("projectless-thread-ids")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToString::to_string)
.collect())
}
fn normalized_path_array(value: &Value) -> Vec<String> {
if let Some(items) = value.as_array() {
items
.iter()
.filter_map(Value::as_str)
.filter_map(normalize_workspace_path)
.collect()
} else {
value
.as_str()
.and_then(normalize_workspace_path)
.into_iter()
.collect()
}
}
fn dedupe_workspace_paths(paths: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
paths
.into_iter()
.filter(|path| {
seen.insert(
path.replace('/', r"\")
.trim_end_matches('\\')
.to_ascii_lowercase(),
)
})
.collect()
}
fn normalized_global_state(state: &Map<String, Value>) -> Map<String, Value> {
let mut next = Map::new();
for key in ["electron-saved-workspace-roots", "project-order"] {
if let Some(value) = state.get(key) {
next.insert(
key.to_string(),
json!(dedupe_workspace_paths(normalized_path_array(value))),
);
}
}
if let Some(value) = state.get("active-workspace-roots") {
let normalized = dedupe_workspace_paths(normalized_path_array(value));
let next_value = if value.is_array() {
json!(normalized)
} else if let Some(first) = normalized.first() {
json!(first)
} else {
value.clone()
};
next.insert("active-workspace-roots".to_string(), next_value);
}
if let Some(labels) = state
.get("electron-workspace-root-labels")
.and_then(Value::as_object)
{
let mut normalized = Map::new();
for (path, value) in labels {
normalized.insert(
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
value.clone(),
);
}
next.insert(
"electron-workspace-root-labels".to_string(),
Value::Object(normalized),
);
}
if let Some(open_targets) = state
.get("open-in-target-preferences")
.and_then(Value::as_object)
{
let mut normalized = open_targets.clone();
if let Some(per_path) = open_targets.get("perPath").and_then(Value::as_object) {
let mut normalized_per_path = Map::new();
for (path, value) in per_path {
normalized_per_path.insert(
normalize_workspace_path(path).unwrap_or_else(|| path.clone()),
value.clone(),
);
}
normalized.insert("perPath".to_string(), Value::Object(normalized_per_path));
}
next.insert(
"open-in-target-preferences".to_string(),
Value::Object(normalized),
);
}
next
}
pub(super) fn count_global_state_updates(path: &Path) -> Result<usize> {
let state = load_global_state(path)?;
let next = normalized_global_state(&state);
Ok(next
.iter()
.filter(|(key, value)| state.get(*key) != Some(*value))
.count())
}
#[cfg(test)]
fn apply_global_state_update(path: &Path) -> Result<usize> {
apply_global_state_update_with_journal(path, &mut Vec::new(), &mut || Ok(()))
}
fn read_optional_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(io_err(path, error)),
}
}
pub(super) fn apply_global_state_update_with_journal<F>(
path: &Path,
writes: &mut Vec<GlobalStateWrite>,
after_main_write: &mut F,
) -> Result<usize>
where
F: FnMut() -> Result<()>,
{
let original_bytes = read_optional_bytes(path)?;
let mut state = match &original_bytes {
Some(bytes) => serde_json::from_slice::<Value>(bytes)
.map_err(|error| json_err(path, error))?
.as_object()
.cloned()
.unwrap_or_default(),
None => Map::new(),
};
let next = normalized_global_state(&state);
let updated = next
.iter()
.filter(|(key, value)| state.get(*key) != Some(*value))
.count();
if updated > 0 {
for (key, value) in next {
state.insert(key, value);
}
let text = serde_json::to_string_pretty(&Value::Object(state))
.map_err(|error| json_err(path, error))?;
let written_bytes = text.as_bytes().to_vec();
if read_optional_bytes(path)? != original_bytes {
return Err(CodexxError::Config(
"全局状态已发生变化,请重试。".to_string(),
));
}
write_text(path, &text)?;
writes.push(GlobalStateWrite {
path: path.to_path_buf(),
original_bytes,
written_bytes: written_bytes.clone(),
});
after_main_write()?;
if let Some(parent) = path.parent() {
let backup = parent.join(".codex-global-state.json.bak");
let original_bytes = read_optional_bytes(&backup)?;
if read_optional_bytes(&backup)? != original_bytes {
return Err(CodexxError::Config(
"全局状态已发生变化,请重试。".to_string(),
));
}
write_text(&backup, &text)?;
writes.push(GlobalStateWrite {
path: backup,
original_bytes,
written_bytes,
});
}
}
Ok(updated)
}
pub(super) fn restore_global_write(write: &GlobalStateWrite) -> Result<()> {
match fs::read(&write.path) {
Ok(current) if current == write.written_bytes => {}
Ok(_) => {
return Err(CodexxError::Config(format!(
"全局状态已发生变化,无法安全恢复: {}",
write.path.display()
)));
}
Err(error)
if error.kind() == std::io::ErrorKind::NotFound && write.original_bytes.is_none() =>
{
return Ok(());
}
Err(error) => return Err(io_err(&write.path, error)),
}
if let Some(original) = &write.original_bytes {
atomic_write(&write.path, original)
} else {
fs::remove_file(&write.path).map_err(|error| io_err(&write.path, error))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_dir(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"codex-x-global-state-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create test directory");
path
}
#[test]
fn global_state_update_keeps_unknown_fields_and_matches_backup() {
let dir = temp_dir("update");
let path = dir.join(".codex-global-state.json");
fs::write(
&path,
r#"{
"electron-saved-workspace-roots": "/tmp/project",
"unrelated-setting": { "enabled": true }
}"#,
)
.expect("write original global state");
assert_eq!(
apply_global_state_update(&path).expect("update global state"),
1
);
let main_text = fs::read_to_string(&path).expect("read global state");
let backup_path = dir.join(".codex-global-state.json.bak");
let backup_text = fs::read_to_string(&backup_path).expect("read global state backup");
assert_eq!(main_text, backup_text);
let state: Value = serde_json::from_str(&main_text).expect("parse global state");
assert_eq!(
state.get("electron-saved-workspace-roots"),
Some(&json!(["/tmp/project"]))
);
assert_eq!(
state.get("unrelated-setting"),
Some(&json!({ "enabled": true }))
);
fs::remove_dir_all(dir).expect("remove test directory");
}
#[test]
fn restores_distinct_existing_global_main_and_backup() {
let dir = temp_dir("distinct-snapshots");
let main = dir.join(".codex-global-state.json");
let backup = dir.join(".codex-global-state.json.bak");
let original_main = br#"{"source":"main","value":1}"#;
let original_backup = br#"{"source":"backup","value":2}"#;
let written_text = r#"{"source":"mutated"}"#.to_string();
fs::write(&main, original_main).expect("write original main");
fs::write(&backup, original_backup).expect("write original backup");
write_text(&main, &written_text).expect("mutate global main");
write_text(&backup, &written_text).expect("mutate global backup");
let writes = [
GlobalStateWrite {
path: main.clone(),
original_bytes: Some(original_main.to_vec()),
written_bytes: written_text.as_bytes().to_vec(),
},
GlobalStateWrite {
path: backup.clone(),
original_bytes: Some(original_backup.to_vec()),
written_bytes: written_text.into_bytes(),
},
];
for write in writes.iter().rev() {
restore_global_write(write).expect("restore global write");
}
assert_eq!(fs::read(&main).expect("read restored main"), original_main);
assert_eq!(
fs::read(&backup).expect("read restored backup"),
original_backup
);
fs::remove_dir_all(dir).expect("remove test directory");
}
}
@@ -0,0 +1,24 @@
mod app_server;
mod backup;
mod delete;
mod global_state;
mod storage;
mod sync;
mod transaction;
mod types;
#[cfg(test)]
pub(crate) use backup::{
backup_sqlite_to_backup, provider_sync_backup_root, prune_provider_sync_backups,
};
#[cfg(test)]
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
pub(crate) use storage::sqlite_candidate_paths;
#[cfg(test)]
pub(crate) use storage::{
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
scan_sqlite, sqlite_session_db_paths,
};
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
use super::global_state::count_global_state_updates;
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
list_session_previews_with_paths, scan_rollouts, scan_sqlite_with_paths, SqliteDiscovery,
};
use super::transaction::{
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
rollback_open_transactions, MutationJournal, MutationPoint,
};
use super::types::{SessionSyncResult, SessionSyncStatus};
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err};
use crate::resolve_codex_dir;
use std::fs;
use std::io::Write;
use std::path::Path;
pub(crate) 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 discovery = discover_sqlite_databases(&codex_dir);
session_sync_status_with_discovery(&codex_dir, target, &discovery)
}
pub(super) fn session_sync_status_with_discovery(
codex_dir: &Path,
target: String,
discovery: &SqliteDiscovery,
) -> Result<SessionSyncStatus> {
let rollouts = scan_rollouts(codex_dir, &target)?;
let sqlite = scan_sqlite_with_paths(&discovery.session_paths, &rollouts, &target)?;
let global_state_updates =
count_global_state_updates(&codex_dir.join(".codex-global-state.json"))?;
let session_limit = sqlite.sqlite_threads.clamp(50, 1000);
let preview_paths = discovery.active_first_session_paths();
let (sessions, session_warnings) =
list_session_previews_with_paths(&preview_paths, &rollouts, &target, session_limit)?;
let mut warnings = rollouts.warnings;
warnings.extend(sqlite.warnings);
warnings.extend(session_warnings);
Ok(SessionSyncStatus {
codex_dir: codex_dir.display().to_string(),
target_provider: target,
rollout_files: rollouts.rollout_files,
session_meta_count: rollouts.session_meta_count,
mismatched_rollouts: rollouts.mismatched_rollouts,
mismatched_session_meta: rollouts.mismatched_session_meta,
sqlite_dbs: sqlite.sqlite_dbs,
sqlite_threads: sqlite.sqlite_threads,
top_level_threads: sqlite.top_level_threads,
subagent_threads: sqlite.subagent_threads,
mismatched_threads: sqlite.mismatched_threads,
needs_sync: rollouts.mismatched_rollouts > 0
|| sqlite.mismatched_threads > 0
|| global_state_updates > 0,
backup_dir: None,
warnings,
sessions,
})
}
pub(super) struct SessionMaintenanceLock {
file: fs::File,
}
impl Drop for SessionMaintenanceLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
pub(super) fn acquire_session_maintenance_lock(codex_dir: &Path) -> Result<SessionMaintenanceLock> {
let tmp_dir = codex_dir.join("tmp");
ensure_directory(&tmp_dir)?;
let legacy_lock = tmp_dir.join("provider-sync.lock");
if legacy_lock.exists() {
return Err(CodexxError::Config(format!(
"会话维护正在进行: {}",
legacy_lock.display()
)));
}
let path = tmp_dir.join("session-maintenance.lock");
if path.is_dir() {
return Err(CodexxError::Config(format!(
"检测到旧版会话维护锁,请确认没有其他 Codex-X 正在维护会话后删除: {}",
path.display()
)));
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(|e| io_err(&path, e))?;
file.try_lock()
.map_err(|_| CodexxError::Config(format!("会话维护正在进行: {}", path.display())))?;
file.set_len(0).map_err(|e| io_err(&path, e))?;
writeln!(file, "pid={}", std::process::id()).map_err(|e| io_err(&path, e))?;
file.sync_all().map_err(|e| io_err(&path, e))?;
Ok(SessionMaintenanceLock { file })
}
pub(crate) fn sync_sessions_provider_inner(
config_dir: Option<String>,
target_provider: Option<String>,
) -> Result<SessionSyncResult> {
sync_sessions_provider_with_hook(config_dir, target_provider, |_| Ok(()))
}
pub(super) fn sync_sessions_provider_with_hook<F>(
config_dir: Option<String>,
target_provider: Option<String>,
mut hook: F,
) -> Result<SessionSyncResult>
where
F: FnMut(MutationPoint) -> Result<()>,
{
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let target_provider = current_model_provider(&codex_dir, target_provider)?;
let _maintenance_lock = acquire_session_maintenance_lock(&codex_dir)?;
let discovery = discover_sqlite_databases(&codex_dir);
ensure_sqlite_discovery_writable(&discovery)?;
let rollouts = scan_rollouts(&codex_dir, &target_provider)?;
let sqlite = scan_sqlite_with_paths(&discovery.session_paths, &rollouts, &target_provider)?;
let global_state_path = codex_dir.join(".codex-global-state.json");
let global_state_updates = count_global_state_updates(&global_state_path)?;
if rollouts.changes.is_empty() && sqlite.mismatched_threads == 0 && global_state_updates == 0 {
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
return Ok(SessionSyncResult {
status,
updated_rollouts: 0,
updated_threads: 0,
backup_dir: String::new(),
});
}
let changed_rollouts = rollouts
.changes
.iter()
.map(|change| change.path.clone())
.collect::<Vec<_>>();
let mut pending_sqlite = prepare_sqlite_updates(&discovery.session_paths)?;
let sqlite_snapshot_paths = pending_sqlite
.iter()
.map(|update| update.path().to_path_buf())
.collect::<Vec<_>>();
let backup = match create_provider_sync_backup(
&codex_dir,
&target_provider,
&changed_rollouts,
&sqlite_snapshot_paths,
) {
Ok(backup) => backup,
Err(error) => {
rollback_open_transactions(&mut pending_sqlite);
return Err(error);
}
};
let mut journal = MutationJournal::default();
let mutation = execute_provider_sync_mutation(
&rollouts,
&mut pending_sqlite,
&target_provider,
&global_state_path,
&mut journal,
&mut hook,
);
let mutation = match mutation {
Ok(result) => result,
Err(error) => {
let recovery_errors = rollback_mutation(&journal, &mut pending_sqlite);
return Err(mutation_error(error, recovery_errors));
}
};
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
let mut status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)
.map_err(|error| {
CodexxError::Config(format!(
"同步已完成,但刷新会话列表失败,请重新进入页面:{error}"
))
})?;
status.backup_dir = Some(backup.dir.display().to_string());
if prune_warning.is_some() {
status
.warnings
.push("同步已完成,但旧备份暂未清理。".to_string());
}
if !mutation.skipped_rollouts.is_empty() {
status.warnings.push(format!(
"有 {} 个会话正在使用,已跳过;退出 Codex 后再同步即可。",
mutation.skipped_rollouts.len()
));
}
Ok(SessionSyncResult {
status,
updated_rollouts: mutation.applied_rollouts,
updated_threads: mutation.sqlite_updates.total(),
backup_dir: backup.dir.display().to_string(),
})
}
@@ -0,0 +1,405 @@
use super::global_state::{
apply_global_state_update_with_journal, restore_global_write, GlobalStateWrite,
};
use super::storage::{apply_session_changes, restore_session_changes};
use super::types::{RolloutScan, SessionFileChange};
use crate::error::{CodexxError, Result};
use crate::file_io::io_err;
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
use rusqlite::{Connection, OpenFlags};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug, Default)]
pub(super) struct SqliteUpdateCounts {
provider_rows: usize,
cwd_rows: usize,
}
impl SqliteUpdateCounts {
pub(super) fn total(&self) -> usize {
self.provider_rows + self.cwd_rows
}
fn add(&mut self, other: Self) {
self.provider_rows += other.provider_rows;
self.cwd_rows += other.cwd_rows;
}
}
#[derive(Debug, Default)]
pub(super) struct MutationJournal {
applied_rollouts: Vec<SessionFileChange>,
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
global_writes: Vec<GlobalStateWrite>,
}
#[derive(Debug, Clone)]
struct SqliteRestoreAttempt {
path: PathBuf,
expected_data_version: i64,
}
pub(super) struct PendingSqliteUpdate {
path: PathBuf,
conn: Connection,
observer: Connection,
columns: HashSet<String>,
counts: SqliteUpdateCounts,
transaction_open: bool,
}
impl PendingSqliteUpdate {
pub(super) fn path(&self) -> &Path {
&self.path
}
}
pub(super) fn rollback_open_transactions(updates: &mut [PendingSqliteUpdate]) {
for update in updates.iter_mut().rev() {
if update.transaction_open {
let _ = update.conn.execute_batch("ROLLBACK");
update.transaction_open = false;
}
}
}
fn sqlite_data_version(conn: &Connection) -> Result<i64> {
conn.query_row("PRAGMA data_version", [], |row| row.get(0))
.map_err(|error| CodexxError::Database(error.to_string()))
}
fn create_sqlite_rollback_table(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TEMP TABLE codexx_session_rollback (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT,
provider_changed INTEGER NOT NULL DEFAULT 0,
cwd_changed INTEGER NOT NULL DEFAULT 0
);",
)
.map_err(|error| CodexxError::Database(error.to_string()))
}
pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<PendingSqliteUpdate>> {
let mut pending = Vec::new();
let mut seen_databases = HashSet::new();
let prepare_result = (|| -> Result<()> {
for path in sqlite_paths {
if !path.exists() {
return Err(CodexxError::Database(format!(
"SQLite 文件不存在: {}",
path.display()
)));
}
let identity = path.canonicalize().map_err(|error| io_err(path, error))?;
if !seen_databases.insert(identity.clone()) {
continue;
}
let conn = Connection::open_with_flags(
&identity,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|error| {
CodexxError::Database(format!("打开 SQLite 失败 {}: {error}", path.display()))
})?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(|error| CodexxError::Database(error.to_string()))?;
if !sqlite_has_table(&conn, "threads")? {
continue;
}
let columns = table_column_set(&conn, "threads")?;
if !columns.contains("id") || !columns.contains("model_provider") {
continue;
}
conn.execute_batch("BEGIN IMMEDIATE")
.map_err(|error| CodexxError::Database(error.to_string()))?;
if let Err(error) = create_sqlite_rollback_table(&conn) {
let _ = conn.execute_batch("ROLLBACK");
return Err(error);
}
let observer = match (|| -> Result<Connection> {
let observer = Connection::open_with_flags(
&identity,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|error| {
CodexxError::Database(format!(
"打开 SQLite 观察连接失败 {}: {error}",
identity.display()
))
})?;
observer
.busy_timeout(Duration::from_secs(5))
.map_err(|error| CodexxError::Database(error.to_string()))?;
sqlite_data_version(&observer)?;
Ok(observer)
})() {
Ok(observer) => observer,
Err(error) => {
let _ = conn.execute_batch("ROLLBACK");
return Err(error);
}
};
pending.push(PendingSqliteUpdate {
path: identity,
conn,
observer,
columns,
counts: SqliteUpdateCounts::default(),
transaction_open: true,
});
}
Ok(())
})();
if let Err(error) = prepare_result {
rollback_open_transactions(&mut pending);
return Err(error);
}
Ok(pending)
}
fn apply_sqlite_updates(
pending: &mut [PendingSqliteUpdate],
rollouts: &RolloutScan,
target_provider: &str,
) -> Result<()> {
for update in pending.iter_mut() {
update
.conn
.execute(
"INSERT INTO temp.codexx_session_rollback
(id, model_provider, provider_changed)
SELECT id, model_provider, 1 FROM threads
WHERE COALESCE(model_provider, '') <> ?1
ON CONFLICT(id) DO UPDATE SET
model_provider = excluded.model_provider,
provider_changed = 1",
[target_provider],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.provider_rows = update
.conn
.execute(
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
[target_provider],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
if update.columns.contains("id") && update.columns.contains("cwd") {
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
update
.conn
.execute(
"INSERT INTO temp.codexx_session_rollback
(id, cwd, cwd_changed)
SELECT id, cwd, 1 FROM threads
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
cwd_changed = 1",
(cwd, thread_id),
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.counts.cwd_rows += update
.conn
.execute(
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
(cwd, thread_id),
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
}
}
}
Ok(())
}
fn commit_sqlite_updates<F>(
pending: &mut [PendingSqliteUpdate],
journal: &mut MutationJournal,
hook: &mut F,
) -> Result<SqliteUpdateCounts>
where
F: FnMut(MutationPoint) -> Result<()>,
{
let mut updated = SqliteUpdateCounts::default();
for index in 0..pending.len() {
let before_commit = sqlite_data_version(&pending[index].observer)?;
journal.sqlite_restore_attempts.push(SqliteRestoreAttempt {
path: pending[index].path.clone(),
expected_data_version: before_commit,
});
let attempt_index = journal.sqlite_restore_attempts.len() - 1;
if let Err(error) = pending[index].conn.execute_batch("COMMIT") {
let rollback_succeeded = !pending[index].conn.is_autocommit()
&& pending[index].conn.execute_batch("ROLLBACK").is_ok()
&& pending[index].conn.is_autocommit();
pending[index].transaction_open = !pending[index].conn.is_autocommit();
if rollback_succeeded {
journal.sqlite_restore_attempts.remove(attempt_index);
}
rollback_open_transactions(&mut pending[index + 1..]);
return Err(CodexxError::Database(error.to_string()));
}
pending[index].transaction_open = false;
journal.sqlite_restore_attempts[attempt_index].expected_data_version =
sqlite_data_version(&pending[index].observer)?;
updated.add(std::mem::take(&mut pending[index].counts));
if let Err(error) = hook(MutationPoint::AfterSqliteCommit(index)) {
rollback_open_transactions(&mut pending[index + 1..]);
return Err(error);
}
}
Ok(updated)
}
fn restore_sqlite_update(
update: &mut PendingSqliteUpdate,
expected_data_version: i64,
) -> Result<()> {
update
.conn
.execute_batch("BEGIN IMMEDIATE")
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.transaction_open = true;
let result = (|| -> Result<()> {
let current = sqlite_data_version(&update.observer)?;
if current != expected_data_version {
return Err(CodexxError::Config(format!(
"会话数据库已发生变化,已保留备份且未覆盖: {}",
update.path.display()
)));
}
let mut statements = vec![
"UPDATE threads
SET model_provider = (
SELECT rollback.model_provider
FROM temp.codexx_session_rollback AS rollback
WHERE rollback.id = threads.id
)
WHERE id IN (
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
)",
];
if update.columns.contains("cwd") {
statements.push(
"UPDATE threads
SET cwd = (
SELECT rollback.cwd
FROM temp.codexx_session_rollback AS rollback
WHERE rollback.id = threads.id
)
WHERE id IN (
SELECT id FROM temp.codexx_session_rollback WHERE cwd_changed = 1
)",
);
}
statements.push("DROP TABLE temp.codexx_session_rollback");
update
.conn
.execute_batch(&statements.join(";"))
.map_err(|error| CodexxError::Database(error.to_string()))?;
update
.conn
.execute_batch("COMMIT")
.map_err(|error| CodexxError::Database(error.to_string()))?;
update.transaction_open = false;
Ok(())
})();
if result.is_err() && update.transaction_open {
let _ = update.conn.execute_batch("ROLLBACK");
update.transaction_open = false;
}
result
}
pub(super) fn rollback_mutation(
journal: &MutationJournal,
pending_sqlite: &mut [PendingSqliteUpdate],
) -> Vec<String> {
let mut errors = Vec::new();
for write in journal.global_writes.iter().rev() {
if let Err(error) = restore_global_write(write) {
errors.push(error.to_string());
}
}
for attempt in journal.sqlite_restore_attempts.iter().rev() {
let Some(update) = pending_sqlite
.iter_mut()
.find(|update| update.path == attempt.path)
else {
errors.push(format!("缺少 SQLite 恢复连接: {}", attempt.path.display()));
continue;
};
if let Err(error) = restore_sqlite_update(update, attempt.expected_data_version) {
errors.push(error.to_string());
}
}
if let Err(error) = restore_session_changes(&journal.applied_rollouts) {
errors.push(error.to_string());
}
errors
}
pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>) -> CodexxError {
if recovery_errors.is_empty() {
original
} else {
CodexxError::Config(format!(
"同步失败,自动恢复也未完成:{original};{}",
recovery_errors.join(";")
))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MutationPoint {
AfterSqliteCommit(usize),
AfterGlobalMainWrite,
}
pub(super) struct MutationResult {
pub(super) applied_rollouts: usize,
pub(super) skipped_rollouts: Vec<PathBuf>,
pub(super) sqlite_updates: SqliteUpdateCounts,
}
pub(super) fn execute_provider_sync_mutation<F>(
rollouts: &RolloutScan,
pending_sqlite: &mut [PendingSqliteUpdate],
target_provider: &str,
global_state_path: &Path,
journal: &mut MutationJournal,
hook: &mut F,
) -> Result<MutationResult>
where
F: FnMut(MutationPoint) -> Result<()>,
{
let result = (|| -> Result<MutationResult> {
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
journal.applied_rollouts = applied_rollouts;
apply_sqlite_updates(pending_sqlite, rollouts, target_provider)?;
apply_global_state_update_with_journal(
global_state_path,
&mut journal.global_writes,
&mut || hook(MutationPoint::AfterGlobalMainWrite),
)?;
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
Ok(MutationResult {
applied_rollouts: journal.applied_rollouts.len(),
skipped_rollouts,
sqlite_updates,
})
})();
if result.is_err() {
rollback_open_transactions(pending_sqlite);
}
result
}
#[cfg(test)]
#[path = "transaction_tests.rs"]
mod tests;
@@ -0,0 +1,488 @@
use super::*;
use crate::sessions::backup::provider_sync_backup_root;
use crate::sessions::sync::sync_sessions_provider_with_hook;
use rusqlite::{Connection, OpenFlags};
use std::fs;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn temp_dir(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"codex-x-session-sync-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create test directory");
path
}
fn write_rollout(path: &Path, id: &str) -> Vec<u8> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create rollout parent");
}
let content = format!(
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\",\"cwd\":\"/tmp/project\"}}}}\n"
);
fs::write(path, content.as_bytes()).expect("write rollout");
content.into_bytes()
}
fn create_thread_database(path: &Path, id: &str, rollout: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create sqlite parent");
}
let conn = Connection::open(path).expect("create thread database");
conn.execute_batch(
"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT NOT NULL,
rollout_path TEXT
);",
)
.expect("create threads table");
conn.execute(
"INSERT INTO threads (id, model_provider, rollout_path)
VALUES (?1, 'openai', ?2)",
(id, rollout.display().to_string()),
)
.expect("insert thread");
}
fn thread_provider(path: &Path, id: &str) -> String {
Connection::open(path)
.expect("open thread database")
.query_row(
"SELECT model_provider FROM threads WHERE id = ?1",
[id],
|row| row.get(0),
)
.expect("read thread provider")
}
fn sqlite_quick_check(path: &Path) -> String {
Connection::open(path)
.expect("open sqlite for quick check")
.query_row("PRAGMA quick_check", [], |row| row.get(0))
.expect("run quick check")
}
#[test]
fn sqlite_update_failure_rolls_back_every_open_database_and_jsonl() {
let codex_dir = temp_dir("multi-sqlite-update-failure");
let id = "019f6000-0000-7000-8000-000000000401";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
let original_rollout = write_rollout(&rollout, id);
let first = codex_dir.join("sqlite/custom.db");
let second = codex_dir.join("state_10.sqlite");
create_thread_database(&first, id, &rollout);
create_thread_database(&second, id, &rollout);
Connection::open(&second)
.expect("open failing database")
.execute_batch(
"CREATE TRIGGER reject_provider_update
BEFORE UPDATE OF model_provider ON threads
BEGIN SELECT RAISE(ABORT, 'provider update blocked'); END;",
)
.expect("install rejecting trigger");
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
|_| Ok(()),
)
.expect_err("second database update must fail");
assert!(error.to_string().contains("provider update blocked"));
assert_eq!(thread_provider(&first, id), "openai");
assert_eq!(thread_provider(&second, id), "openai");
assert_eq!(
fs::read(&rollout).expect("read restored rollout"),
original_rollout
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sqlite_commit_failpoint_restores_committed_and_uncommitted_databases() {
let codex_dir = temp_dir("sqlite-commit-failpoint");
let id = "019f6000-0000-7000-8000-000000000406";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
let original_rollout = write_rollout(&rollout, id);
let first = codex_dir.join("sqlite/custom.db");
let second = codex_dir.join("state_10.sqlite");
create_thread_database(&first, id, &rollout);
create_thread_database(&second, id, &rollout);
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
|point| match point {
MutationPoint::AfterSqliteCommit(0) => {
Err(CodexxError::Config("提交后注入失败".to_string()))
}
_ => Ok(()),
},
)
.expect_err("fail after first sqlite commit");
assert_eq!(error.to_string(), "配置错误: 提交后注入失败");
assert_eq!(thread_provider(&first, id), "openai");
assert_eq!(thread_provider(&second, id), "openai");
assert_eq!(
fs::read(&rollout).expect("read restored rollout"),
original_rollout
);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn failed_sqlite_commit_rolls_back_without_snapshot_restore() {
let codex_dir = temp_dir("failed-sqlite-commit");
let id = "019f6000-0000-7000-8000-000000000407";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
write_rollout(&rollout, id);
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id, &rollout);
Connection::open(&database)
.expect("open database for deferred constraint")
.execute_batch(
"CREATE TABLE parent (id TEXT PRIMARY KEY);
CREATE TABLE child (
parent_id TEXT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED
);",
)
.expect("create deferred foreign key");
let conn = Connection::open(&database).expect("open writer");
conn.pragma_update(None, "foreign_keys", true)
.expect("enable foreign keys");
conn.execute_batch(
"BEGIN IMMEDIATE;
INSERT INTO child (parent_id) VALUES ('missing');",
)
.expect("defer invalid foreign key until commit");
let observer = Connection::open_with_flags(
&database,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.expect("open observer");
let mut pending = vec![PendingSqliteUpdate {
path: database.clone(),
conn,
observer,
columns: HashSet::new(),
counts: SqliteUpdateCounts::default(),
transaction_open: true,
}];
let mut journal = MutationJournal::default();
let error = commit_sqlite_updates(&mut pending, &mut journal, &mut |_| Ok(()))
.expect_err("deferred foreign key must reject commit");
assert!(error.to_string().contains("FOREIGN KEY constraint failed"));
assert!(journal.sqlite_restore_attempts.is_empty());
assert!(!pending[0].transaction_open);
let child_rows: i64 = Connection::open(&database)
.expect("reopen database")
.query_row("SELECT COUNT(*) FROM child", [], |row| row.get(0))
.expect("count rolled-back child rows");
assert_eq!(child_rows, 0);
drop(pending);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sqlite_restore_does_not_overwrite_new_codex_writes() {
let codex_dir = temp_dir("sqlite-concurrent-write");
let id = "019f6000-0000-7000-8000-000000000409";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
let original_rollout = write_rollout(&rollout, id);
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id, &rollout);
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
|point| match point {
MutationPoint::AfterSqliteCommit(0) => {
Connection::open(&database)
.expect("open concurrent Codex writer")
.execute_batch(
"CREATE TABLE concurrent_marker (value TEXT NOT NULL);
INSERT INTO concurrent_marker (value) VALUES ('keep-new-write');",
)
.expect("write after provider sync commit");
Err(CodexxError::Config("并发写入后注入失败".to_string()))
}
_ => Ok(()),
},
)
.expect_err("recovery must detect the concurrent write");
assert!(error.to_string().contains("会话数据库已发生变化"));
assert_eq!(thread_provider(&database, id), "custom");
let marker: String = Connection::open(&database)
.expect("open database with concurrent write")
.query_row("SELECT value FROM concurrent_marker", [], |row| row.get(0))
.expect("read concurrent marker");
assert_eq!(marker, "keep-new-write");
assert_eq!(
fs::read(&rollout).expect("read restored rollout"),
original_rollout
);
assert!(fs::read_dir(provider_sync_backup_root(&codex_dir))
.expect("read retained provider sync backup")
.next()
.is_some());
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn sqlite_restore_rechecks_after_waiting_for_a_writer() {
let codex_dir = temp_dir("sqlite-writer-during-restore");
let id = "019f6000-0000-7000-8000-000000000412";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
write_rollout(&rollout, id);
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id, &rollout);
let mut writer = None;
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
|point| match point {
MutationPoint::AfterSqliteCommit(0) => {
let writer_database = database.clone();
let (ready_tx, ready_rx) = mpsc::channel();
writer = Some(thread::spawn(move || {
let conn = Connection::open(writer_database).expect("open concurrent writer");
conn.execute_batch(
"BEGIN IMMEDIATE;
CREATE TABLE concurrent_during_restore (value TEXT NOT NULL);
INSERT INTO concurrent_during_restore (value)
VALUES ('keep-writer-commit');",
)
.expect("stage concurrent write");
ready_tx.send(()).expect("signal writer lock");
thread::sleep(Duration::from_millis(150));
conn.execute_batch("COMMIT")
.expect("commit while recovery waits");
}));
ready_rx
.recv_timeout(Duration::from_secs(2))
.expect("wait for concurrent writer lock");
Err(CodexxError::Config("持锁写入期间注入失败".to_string()))
}
_ => Ok(()),
},
)
.expect_err("recovery must recheck after waiting for the writer");
writer
.take()
.expect("writer handle")
.join()
.expect("join concurrent writer");
assert!(error.to_string().contains("会话数据库已发生变化"));
assert_eq!(thread_provider(&database, id), "custom");
let marker: String = Connection::open(&database)
.expect("open database after concurrent commit")
.query_row("SELECT value FROM concurrent_during_restore", [], |row| {
row.get(0)
})
.expect("read concurrent marker");
assert_eq!(marker, "keep-writer-commit");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[cfg(unix)]
#[test]
fn sqlite_prepare_deduplicates_symlink_aliases() {
use std::os::unix::fs::symlink;
let codex_dir = temp_dir("sqlite-symlink-alias");
let id = "019f6000-0000-7000-8000-000000000410";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
write_rollout(&rollout, id);
let database = codex_dir.join("state_10.sqlite");
let alias = codex_dir.join("state-alias.sqlite");
create_thread_database(&database, id, &rollout);
symlink(&database, &alias).expect("create database symlink");
let mut pending =
prepare_sqlite_updates(&[database.clone(), alias]).expect("prepare aliased database once");
assert_eq!(pending.len(), 1);
assert_eq!(
pending[0].path,
database.canonicalize().expect("canonical db")
);
rollback_open_transactions(&mut pending);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn global_main_failpoint_restores_prior_mutations_without_touching_backup() {
let codex_dir = temp_dir("global-main-failpoint");
let id = "019f6000-0000-7000-8000-000000000408";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
let original_rollout = write_rollout(&rollout, id);
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id, &rollout);
let global = codex_dir.join(".codex-global-state.json");
let global_backup = codex_dir.join(".codex-global-state.json.bak");
let original_global = br#"{"electron-saved-workspace-roots":"/tmp/project"}"#;
fs::write(&global, original_global).expect("write original global state");
assert!(!global_backup.exists());
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
|point| match point {
MutationPoint::AfterGlobalMainWrite => {
assert_eq!(thread_provider(&database, id), "openai");
assert!(fs::read_to_string(&rollout)
.expect("read mutated rollout")
.contains("\"model_provider\":\"custom\""));
assert!(!global_backup.exists());
Err(CodexxError::Config("主状态写入后注入失败".to_string()))
}
_ => Ok(()),
},
)
.expect_err("fail after global main write");
assert_eq!(error.to_string(), "配置错误: 主状态写入后注入失败");
assert_eq!(thread_provider(&database, id), "openai");
assert_eq!(
fs::read(&rollout).expect("read restored rollout"),
original_rollout
);
assert_eq!(
fs::read(&global).expect("read restored global"),
original_global
);
assert!(!global_backup.exists());
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn injected_failure_restores_sqlite_jsonl_and_global_state() {
let codex_dir = temp_dir("full-mutation-rollback");
let id = "019f6000-0000-7000-8000-000000000411";
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
write_rollout(&rollout, id);
let mut rollout_file = fs::OpenOptions::new()
.append(true)
.open(&rollout)
.expect("open rollout for user event");
writeln!(
rollout_file,
"{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"user_message\",\"message\":\"hello\"}}}}"
)
.expect("append user event");
drop(rollout_file);
let original_rollout = fs::read(&rollout).expect("read original rollout");
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id, &rollout);
let wal_guard = Connection::open(&database).expect("open database for WAL mode");
wal_guard
.pragma_update(None, "journal_mode", "WAL")
.expect("enable WAL mode");
wal_guard
.execute_batch(
"ALTER TABLE threads ADD COLUMN has_user_event INTEGER DEFAULT 0;
ALTER TABLE threads ADD COLUMN cwd TEXT;
UPDATE threads SET cwd = '/tmp/wrong';
CREATE TABLE rollback_marker (value TEXT NOT NULL);
INSERT INTO rollback_marker (value) VALUES ('keep-me');",
)
.expect("write WAL marker");
let wal_path = PathBuf::from(format!("{}-wal", database.display()));
assert!(wal_path.exists());
let global = codex_dir.join(".codex-global-state.json");
let global_backup = codex_dir.join(".codex-global-state.json.bak");
let original_global = br#"{
"electron-saved-workspace-roots": "/tmp/project",
"unrelated-setting": true
}"#;
fs::write(&global, original_global).expect("write original global state");
assert!(!global_backup.exists());
let mut hook_called = false;
let error = sync_sessions_provider_with_hook(
Some(codex_dir.display().to_string()),
Some("custom".to_string()),
|point| match point {
MutationPoint::AfterSqliteCommit(0) => {
hook_called = true;
assert_eq!(thread_provider(&database, id), "custom");
let user_event_flag: i64 = Connection::open(&database)
.expect("open committed session metadata")
.query_row(
"SELECT has_user_event FROM threads WHERE id = ?1",
[id],
|row| row.get(0),
)
.expect("read committed user event flag");
assert_eq!(user_event_flag, 0);
assert!(fs::read_to_string(&rollout)
.expect("read mutated rollout")
.contains("\"model_provider\":\"custom\""));
let main = fs::read_to_string(&global).expect("read mutated global state");
let backup =
fs::read_to_string(&global_backup).expect("read mutated global backup");
assert_eq!(main, backup);
Err(CodexxError::Config("测试注入失败".to_string()))
}
_ => Ok(()),
},
)
.expect_err("hook must fail after all writes");
assert!(hook_called);
assert_eq!(error.to_string(), "配置错误: 测试注入失败");
assert_eq!(thread_provider(&database, id), "openai");
let restored_index: (i64, String) = Connection::open(&database)
.expect("open restored session metadata")
.query_row(
"SELECT has_user_event, cwd FROM threads WHERE id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("read restored session metadata");
assert_eq!(restored_index, (0, "/tmp/wrong".to_string()));
assert_eq!(sqlite_quick_check(&database), "ok");
let marker: String = Connection::open(&database)
.expect("open restored database")
.query_row("SELECT value FROM rollback_marker", [], |row| row.get(0))
.expect("read restored WAL marker");
assert_eq!(marker, "keep-me");
assert_eq!(
fs::read(&rollout).expect("read restored rollout"),
original_rollout
);
assert_eq!(
fs::read(&global).expect("read restored global"),
original_global
);
assert!(!global_backup.exists());
assert!(fs::read_dir(provider_sync_backup_root(&codex_dir))
.expect("read retained provider sync backup")
.next()
.is_some());
drop(wal_guard);
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
@@ -0,0 +1,78 @@
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SessionPreview {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) model_provider: Option<String>,
pub(crate) model: Option<String>,
pub(crate) cwd: Option<String>,
pub(crate) rollout_path: Option<String>,
pub(crate) updated_at_ms: Option<i64>,
pub(crate) archived: bool,
pub(crate) has_user_event: bool,
pub(crate) is_subagent: bool,
pub(crate) needs_sync: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SessionSyncStatus {
pub(crate) codex_dir: String,
pub(crate) target_provider: String,
pub(crate) rollout_files: usize,
pub(crate) session_meta_count: usize,
pub(crate) mismatched_rollouts: usize,
pub(crate) mismatched_session_meta: usize,
pub(crate) sqlite_dbs: usize,
pub(crate) sqlite_threads: usize,
pub(crate) top_level_threads: usize,
pub(crate) subagent_threads: usize,
pub(crate) mismatched_threads: usize,
pub(crate) needs_sync: bool,
pub(crate) backup_dir: Option<String>,
pub(crate) warnings: Vec<String>,
pub(crate) sessions: Vec<SessionPreview>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SessionSyncResult {
pub(crate) status: SessionSyncStatus,
pub(crate) updated_rollouts: usize,
pub(crate) updated_threads: usize,
pub(crate) backup_dir: String,
}
#[derive(Debug, Default)]
pub(crate) struct RolloutScan {
pub(crate) rollout_files: usize,
pub(crate) session_meta_count: usize,
pub(crate) mismatched_rollouts: usize,
pub(crate) mismatched_session_meta: usize,
pub(crate) changes: Vec<SessionFileChange>,
pub(crate) cwd_by_thread_id: HashMap<String, String>,
pub(crate) warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionFileChange {
pub(crate) path: PathBuf,
pub(crate) original_text: String,
pub(crate) next_text: String,
pub(crate) original_mtime: Option<SystemTime>,
}
#[derive(Debug, Default)]
pub(crate) struct SqliteScan {
pub(crate) sqlite_dbs: usize,
pub(crate) sqlite_threads: usize,
pub(crate) top_level_threads: usize,
pub(crate) subagent_threads: usize,
pub(crate) mismatched_threads: usize,
pub(crate) warnings: Vec<String>,
}
@@ -0,0 +1,398 @@
use super::build_skills_mcp_state_inner;
use super::types::{ManagedMcpServer, SkillsMcpState};
use crate::ccswitch::default_ccswitch_db_path;
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
use crate::toml_utils::ensure_table;
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
use rusqlite::{params, Connection, OpenFlags};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::Path;
use toml_edit::{value, Item, Table};
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
if let Some(s) = value.as_str() {
return json!(s);
}
if let Some(i) = value.as_integer() {
return json!(i);
}
if let Some(f) = value.as_float() {
return json!(f);
}
if let Some(b) = value.as_bool() {
return json!(b);
}
if let Some(arr) = value.as_array() {
return Value::Array(arr.iter().map(toml_value_to_json).collect());
}
Value::String(value.to_string())
}
fn toml_item_to_json(item: &Item) -> Value {
if let Some(v) = item.as_value() {
return toml_value_to_json(v);
}
if let Some(tbl) = item.as_table() {
let mut obj = serde_json::Map::new();
for (k, v) in tbl.iter() {
obj.insert(k.to_string(), toml_item_to_json(v));
}
return Value::Object(obj);
}
Value::Null
}
fn json_to_toml_item(value_json: &Value) -> Item {
match value_json {
Value::String(s) => value(s.clone()),
Value::Bool(b) => value(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
value(i)
} else if let Some(f) = n.as_f64() {
value(f)
} else {
value(n.to_string())
}
}
Value::Array(arr) => {
let mut toml_arr = toml_edit::Array::default();
for item in arr {
match item {
Value::String(s) => {
toml_arr.push(s.as_str());
}
Value::Bool(b) => {
toml_arr.push(*b);
}
Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml_arr.push(i);
} else if let Some(f) = n.as_f64() {
toml_arr.push(f);
}
}
_ => {}
}
}
value(toml_arr)
}
Value::Object(obj) => {
let mut table = Table::new();
for (k, v) in obj {
table.insert(k, json_to_toml_item(v));
}
Item::Table(table)
}
Value::Null => value(""),
}
}
pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<String>, String) {
let transport = config
.get("type")
.and_then(Value::as_str)
.unwrap_or_else(|| {
if config.get("url").is_some() {
"http"
} else {
"stdio"
}
})
.to_string();
let command = config
.get("command")
.and_then(Value::as_str)
.map(ToString::to_string);
let url = config
.get("url")
.and_then(Value::as_str)
.map(ToString::to_string);
let args = config
.get("args")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let summary = if let Some(cmd) = &command {
if args.is_empty() {
cmd.clone()
} else {
format!("{cmd} {args}")
}
} else if let Some(url) = &url {
url.clone()
} else {
transport.clone()
};
(transport, command, url, summary)
}
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
let conn = open_db()?;
conn.execute(
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
server_config = excluded.server_config,
enabled = excluded.enabled,
updated_at = excluded.updated_at",
params![
id,
name,
serde_json::to_string(config).unwrap_or_default(),
enabled,
now_rfc3339()
],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
let conn = open_db()?;
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
let text: String = row.get(2)?;
let config = serde_json::from_str(&text).unwrap_or(Value::Object(Default::default()));
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
config,
row.get::<_, bool>(3)?,
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(|e| CodexxError::Database(e.to_string()))?);
}
Ok(out)
}
pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpServer>> {
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
if text.trim().is_empty() {
return Ok(vec![]);
}
let doc = parse_toml_document(&cfg, &text)?;
let Some(mcp_item) = doc.get("mcp_servers") else {
return Ok(vec![]);
};
let Some(mcp_tbl) = mcp_item.as_table() else {
return Ok(vec![]);
};
let mut out = Vec::new();
for (id, item) in mcp_tbl.iter() {
if !item.is_table() {
continue;
}
let config = toml_item_to_json(item);
let (transport, command, url, summary) = mcp_summary(&config);
out.push(ManagedMcpServer {
id: id.to_string(),
name: id.to_string(),
transport,
enabled: true,
source: "config.toml".to_string(),
summary,
command,
url,
config_json: config,
});
}
Ok(out)
}
pub(crate) fn sort_managed_mcp_servers(servers: &mut [ManagedMcpServer]) {
servers.sort_by(|a, b| {
a.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
.then_with(|| a.id.cmp(&b.id))
});
}
pub(super) fn import_ccswitch_mcp_servers_for_codex(
codex_dir: &Path,
imported_ids: &mut HashSet<String>,
) -> Result<usize> {
let db = default_ccswitch_db_path()?;
if !db.exists() {
return Ok(0);
}
let conn = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
CodexxError::Database(format!(
"打开 cc-switch MCP 数据库失败 {}: {e}",
db.display()
))
})?;
let mut stmt = match conn
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
.or_else(|_| {
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
}) {
Ok(stmt) => stmt,
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
if message.to_lowercase().contains("no such table") =>
{
return Ok(0);
}
Err(e) => return Err(CodexxError::Database(e.to_string())),
};
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, bool>(3)?,
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let cfg = config_path(codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
let live_enabled = list_mcp_from_config(codex_dir)?
.into_iter()
.map(|server| server.id)
.collect::<HashSet<_>>();
let mut imported = 0usize;
let mut changed_config = false;
for row in rows {
let (id, name, config_text, enabled_codex) =
row.map_err(|e| CodexxError::Database(e.to_string()))?;
let config: Value =
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
if !imported_ids.insert(id.clone()) {
continue;
}
let enabled = enabled_codex || live_enabled.contains(&id);
save_managed_mcp(&id, &name, &config, enabled)?;
if enabled_codex && !live_enabled.contains(&id) {
ensure_table(doc.as_table_mut(), "mcp_servers")?
.insert(&id, json_to_toml_item(&config));
changed_config = true;
}
imported += 1;
}
if changed_config {
write_text(&cfg, &doc.to_string())?;
}
Ok(imported)
}
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
codex_dir: &Path,
) -> Result<Vec<ManagedMcpServer>> {
let db = default_ccswitch_db_path()?;
if !db.exists() {
return Ok(vec![]);
}
let conn = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
CodexxError::Database(format!(
"打开 cc-switch MCP 数据库失败 {}: {e}",
db.display()
))
})?;
let mut stmt = match conn
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
.or_else(|_| {
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
}) {
Ok(stmt) => stmt,
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
if message.to_lowercase().contains("no such table") =>
{
return Ok(vec![]);
}
Err(e) => return Err(CodexxError::Database(e.to_string())),
};
let live_enabled = list_mcp_from_config(codex_dir)?
.into_iter()
.map(|server| server.id)
.collect::<HashSet<_>>();
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, bool>(3)?,
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut out = Vec::new();
for row in rows {
let (id, name, config_text, enabled_codex) =
row.map_err(|e| CodexxError::Database(e.to_string()))?;
let config: Value =
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
let (transport, command, url, summary) = mcp_summary(&config);
out.push(ManagedMcpServer {
id: id.clone(),
name,
transport,
enabled: enabled_codex || live_enabled.contains(&id),
source: "cc-switch".to_string(),
summary,
command,
url,
config_json: config,
});
}
Ok(out)
}
pub(crate) fn toggle_codex_mcp_inner(
config_dir: Option<String>,
id: String,
enabled: bool,
) -> Result<SkillsMcpState> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
ensure_directory(&codex_dir)?;
let cfg = config_path(&codex_dir);
let text = read_to_string_if_exists(&cfg)?;
let mut doc = parse_toml_document(&cfg, &text)?;
if enabled {
let db = db_managed_mcp()?
.into_iter()
.find(|(sid, _, _, _)| sid == &id)
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
save_managed_mcp(&id, &db.1, &db.2, true)?;
} else {
if let Some(item) = doc
.get("mcp_servers")
.and_then(|m| m.as_table())
.and_then(|tbl| tbl.get(&id))
{
let config = toml_item_to_json(item);
save_managed_mcp(&id, &id, &config, false)?;
}
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
tbl.remove(&id);
}
}
write_text(&cfg, &doc.to_string())?;
build_skills_mcp_state_inner(config_dir)
}
@@ -0,0 +1,274 @@
mod mcp;
mod skills;
mod types;
pub(crate) use mcp::{sort_managed_mcp_servers, toggle_codex_mcp_inner};
pub(crate) use skills::{
check_skill_updates_inner, install_skill_zip_inner, normalize_legacy_zip_skill_dirs,
sort_managed_skills, toggle_codex_skill_inner,
};
pub(crate) use types::{
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
};
#[cfg(test)]
pub(crate) use skills::read_skill_metadata;
#[cfg(test)]
pub(crate) use types::ManagedSkill;
use crate::error::Result;
use crate::file_io::{ensure_directory, io_err};
use crate::paths::home_dir;
use crate::resolve_codex_dir;
use mcp::{
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
};
use skills::{
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
};
use std::collections::HashSet;
use std::fs;
fn extend_unmanaged_mcp_candidates(
output: &mut Vec<ManagedMcpServer>,
seen_ids: &mut HashSet<String>,
candidates: impl IntoIterator<Item = ManagedMcpServer>,
) {
for server in candidates {
if seen_ids.insert(server.id.clone()) {
output.push(server);
}
}
}
pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
let codex_dir = resolve_codex_dir(config_dir)?;
let skills_dir = codex_skills_dir(&codex_dir);
let disabled_dir = disabled_skills_dir()?;
let mut warnings = Vec::new();
let mut skills = Vec::new();
let mut seen = HashSet::new();
if let Err(e) = normalize_legacy_zip_skill_dirs(&skills_dir) {
warnings.push(format!("修正 ZIP Skill 目录名失败: {e}"));
}
if let Err(e) = normalize_legacy_zip_skill_dirs(&disabled_dir) {
warnings.push(format!("修正已禁用 ZIP Skill 目录名失败: {e}"));
}
if let Err(e) = scan_skill_dir(&skills_dir, true, "Codex", &mut skills, &mut seen) {
warnings.push(e.to_string());
}
if let Err(e) = scan_skill_dir(
&disabled_dir,
false,
"Codex-X 已禁用",
&mut skills,
&mut seen,
) {
warnings.push(e.to_string());
}
let mut mcp_servers = list_mcp_from_config(&codex_dir)?;
let enabled_ids: HashSet<String> = mcp_servers.iter().map(|s| s.id.clone()).collect();
for (id, name, config, enabled) in db_managed_mcp()? {
if enabled_ids.contains(&id) {
continue;
}
let (transport, command, url, summary) = mcp_summary(&config);
mcp_servers.push(ManagedMcpServer {
id,
name,
transport,
enabled,
source: "Codex-X".to_string(),
summary,
command,
url,
config_json: config,
});
}
sort_managed_mcp_servers(&mut mcp_servers);
sort_managed_skills(&mut skills);
Ok(SkillsMcpState {
codex_dir: codex_dir.display().to_string(),
codex_skills_dir: skills_dir.display().to_string(),
disabled_skills_dir: disabled_dir.display().to_string(),
skills,
mcp_servers,
warnings,
})
}
pub(crate) fn import_existing_skills_mcp_inner(
config_dir: Option<String>,
) -> Result<SkillsMcpActionResult> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let skills_dir = codex_skills_dir(&codex_dir);
ensure_directory(&skills_dir)?;
let mut imported_skills = 0usize;
let candidates = vec![
home_dir()?.join(".agents").join("skills"),
home_dir()?.join(".cc-switch").join("skills"),
];
for base in candidates {
if !base.exists() {
continue;
}
for entry in fs::read_dir(&base).map_err(|e| io_err(&base, e))? {
let entry = entry.map_err(|e| io_err(&base, e))?;
let src = entry.path();
if !src.is_dir() || !src.join("SKILL.md").is_file() {
continue;
}
let directory = sanitize_dir_name(&entry.file_name().to_string_lossy(), "skill");
let dst = skills_dir.join(&directory);
if !dst.exists() {
copy_dir_recursive(&src, &dst)?;
imported_skills += 1;
}
}
}
let mut imported_mcp = 0usize;
let mut imported_mcp_ids = db_managed_mcp()?
.into_iter()
.map(|(id, _, _, _)| id)
.collect::<HashSet<_>>();
for server in list_mcp_from_config(&codex_dir)? {
if !imported_mcp_ids.insert(server.id.clone()) {
continue;
}
save_managed_mcp(&server.id, &server.name, &server.config_json, true)?;
imported_mcp += 1;
}
imported_mcp += import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
let state = build_skills_mcp_state_inner(config_dir)?;
Ok(SkillsMcpActionResult {
imported_skills,
imported_mcp,
message: format!("已导入 {imported_skills} 个 Skills,纳管 {imported_mcp} 个 MCP"),
state,
})
}
pub(crate) fn preview_existing_skills_mcp_inner(
config_dir: Option<String>,
) -> Result<SkillsMcpImportPreview> {
let codex_dir = resolve_codex_dir(config_dir)?;
let skills_dir = codex_skills_dir(&codex_dir);
let mut warnings = Vec::new();
let mut skills = Vec::new();
let mut seen = HashSet::new();
let candidates = vec![
home_dir()?.join(".agents").join("skills"),
home_dir()?.join(".cc-switch").join("skills"),
];
for base in candidates {
if !base.exists() {
continue;
}
let source = base
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "外部目录".to_string());
let before = skills.len();
if let Err(e) = scan_skill_dir(&base, false, &source, &mut skills, &mut seen) {
warnings.push(e.to_string());
}
for skill in &mut skills[before..] {
if skills_dir.join(&skill.directory).exists() {
skill.update_status = "已存在,将跳过".to_string();
} else {
skill.update_status = "可导入".to_string();
}
}
}
skills.retain(|skill| skill.update_status != "已存在,将跳过");
let mut config_mcp_servers = list_mcp_from_config(&codex_dir)?;
for server in &mut config_mcp_servers {
server.source = "config.toml".to_string();
}
let mut seen_mcp = db_managed_mcp()?
.into_iter()
.map(|(id, _, _, _)| id)
.collect::<HashSet<_>>();
let mut mcp_servers = Vec::new();
extend_unmanaged_mcp_candidates(&mut mcp_servers, &mut seen_mcp, config_mcp_servers);
extend_unmanaged_mcp_candidates(
&mut mcp_servers,
&mut seen_mcp,
preview_ccswitch_mcp_servers_for_codex(&codex_dir)?,
);
sort_managed_skills(&mut skills);
sort_managed_mcp_servers(&mut mcp_servers);
Ok(SkillsMcpImportPreview {
skills,
mcp_servers,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn mcp_candidate(id: &str, source: &str) -> ManagedMcpServer {
ManagedMcpServer {
id: id.to_string(),
name: id.to_string(),
transport: "stdio".to_string(),
enabled: true,
source: source.to_string(),
summary: id.to_string(),
command: Some(id.to_string()),
url: None,
config_json: json!({ "command": id }),
}
}
#[test]
fn mcp_import_preview_excludes_managed_and_duplicate_ids() {
let mut seen = HashSet::from(["alpha".to_string()]);
let mut candidates = Vec::new();
extend_unmanaged_mcp_candidates(
&mut candidates,
&mut seen,
[
mcp_candidate("alpha", "config.toml"),
mcp_candidate("beta", "config.toml"),
],
);
extend_unmanaged_mcp_candidates(
&mut candidates,
&mut seen,
[
mcp_candidate("beta", "cc-switch"),
mcp_candidate("gamma", "cc-switch"),
],
);
assert_eq!(
candidates
.iter()
.map(|server| (server.id.as_str(), server.source.as_str()))
.collect::<Vec<_>>(),
vec![("beta", "config.toml"), ("gamma", "cc-switch")]
);
let mut imported_ids = seen;
let mut second_preview = Vec::new();
extend_unmanaged_mcp_candidates(
&mut second_preview,
&mut imported_ids,
[
mcp_candidate("alpha", "config.toml"),
mcp_candidate("beta", "config.toml"),
mcp_candidate("gamma", "cc-switch"),
],
);
assert!(second_preview.is_empty());
}
}
@@ -0,0 +1,617 @@
use super::build_skills_mcp_state_inner;
use super::types::{CcSwitchSkillMeta, ManagedSkill, SkillsMcpActionResult, SkillsMcpState};
use crate::ccswitch::default_ccswitch_db_path;
use crate::constants::MAX_SKILL_ZIP_BYTES;
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err, read_to_string_if_exists};
use crate::paths::app_home;
use crate::{now_rfc3339, open_db, resolve_codex_dir};
use chrono::Local;
use rusqlite::{params, Connection, OpenFlags};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
pub(super) fn codex_skills_dir(codex_dir: &Path) -> PathBuf {
codex_dir.join("skills")
}
pub(super) fn disabled_skills_dir() -> Result<PathBuf> {
Ok(app_home()?.join("disabled-skills"))
}
pub(super) fn sanitize_dir_name(input: &str, fallback: &str) -> String {
let raw = input.trim().trim_matches('/').trim_matches('\\');
let base = if raw.is_empty() { fallback } else { raw };
let mut out = String::new();
let mut last_dash = false;
for ch in base.to_ascii_lowercase().chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let out = out.trim_matches('-');
if out.is_empty() {
fallback.to_string()
} else {
out.to_string()
}
}
pub(super) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
ensure_directory(dst)?;
for entry in fs::read_dir(src).map_err(|e| io_err(src, e))? {
let entry = entry.map_err(|e| io_err(src, e))?;
let path = entry.path();
let file_name = entry.file_name();
let target = dst.join(file_name);
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
if meta.file_type().is_symlink() {
continue;
}
if meta.is_dir() {
copy_dir_recursive(&path, &target)?;
} else if meta.is_file() {
if let Some(parent) = target.parent() {
ensure_directory(parent)?;
}
fs::copy(&path, &target).map_err(|e| io_err(&target, e))?;
}
}
Ok(())
}
fn compute_dir_hash(dir: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
fn collect(base: &Path, current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
let entry = entry.map_err(|e| io_err(current, e))?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
if meta.file_type().is_symlink() {
continue;
}
if meta.is_dir() {
collect(base, &path, out)?;
} else if meta.is_file() {
out.push(path);
}
}
let _ = base;
Ok(())
}
let mut files = Vec::new();
collect(dir, dir, &mut files)?;
files.sort();
let mut hasher = Sha256::new();
for path in files {
let rel = path
.strip_prefix(dir)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
hasher.update(rel.as_bytes());
hasher.update(b"\0");
let bytes = fs::read(&path).map_err(|e| io_err(&path, e))?;
hasher.update(&bytes);
hasher.update(b"\0");
}
Ok(format!("{:x}", hasher.finalize()))
}
fn clean_skill_metadata_value(value: &str) -> String {
value
.trim()
.trim_matches('`')
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string()
}
pub(crate) fn read_skill_metadata(skill_dir: &Path, fallback: &str) -> (String, Option<String>) {
let skill_md = skill_dir.join("SKILL.md");
let text = read_to_string_if_exists(&skill_md).unwrap_or_default();
let mut frontmatter_name: Option<String> = None;
let mut frontmatter_desc: Option<String> = None;
let mut heading_title: Option<String> = None;
let mut body_desc: Option<String> = None;
let mut in_frontmatter = false;
let mut frontmatter_seen = false;
for (index, line) in text.lines().enumerate() {
let trimmed = line.trim();
if index == 0 && trimmed == "---" {
in_frontmatter = true;
frontmatter_seen = true;
continue;
}
if in_frontmatter {
if trimmed == "---" {
in_frontmatter = false;
continue;
}
if let Some((key, value)) = trimmed.split_once(':') {
let key = key.trim().to_ascii_lowercase();
let value = clean_skill_metadata_value(value);
if key == "name" && frontmatter_name.is_none() && !value.is_empty() {
frontmatter_name = Some(value);
} else if key == "description" && frontmatter_desc.is_none() && !value.is_empty() {
frontmatter_desc = Some(value);
}
}
continue;
}
if trimmed.is_empty() {
continue;
}
if heading_title.is_none() && trimmed.starts_with('#') {
heading_title = Some(trimmed.trim_start_matches('#').trim().to_string());
continue;
}
if body_desc.is_none()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with("---")
&& !frontmatter_seen
{
body_desc = Some(clean_skill_metadata_value(trimmed));
break;
}
}
let title = frontmatter_name
.or(heading_title)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback.to_string());
let desc = frontmatter_desc.or(body_desc).filter(|s| !s.is_empty());
(title, desc)
}
pub(crate) fn normalize_legacy_zip_skill_dirs(base: &Path) -> Result<()> {
if !base.exists() {
return Ok(());
}
for entry in fs::read_dir(base).map_err(|e| io_err(base, e))? {
let entry = entry.map_err(|e| io_err(base, e))?;
let path = entry.path();
if !path.is_dir() || !path.join("SKILL.md").is_file() {
continue;
}
let directory = entry.file_name().to_string_lossy().to_string();
if !directory.starts_with("skill-zip-") {
continue;
}
let (name, _) = read_skill_metadata(&path, &directory);
let dst_name = sanitize_dir_name(&name, "skill");
if dst_name == directory || dst_name.starts_with("skill-zip-") {
continue;
}
let dst = base.join(dst_name);
if dst.exists() {
continue;
}
fs::rename(&path, &dst).map_err(|e| io_err(&dst, e))?;
}
Ok(())
}
fn save_managed_skill(skill: &ManagedSkill) -> Result<()> {
let conn = open_db()?;
conn.execute(
"INSERT INTO managed_skills (id, name, description, directory, source_path, content_hash, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
directory = excluded.directory,
source_path = excluded.source_path,
content_hash = excluded.content_hash,
enabled = excluded.enabled,
updated_at = excluded.updated_at",
params![
skill.id,
skill.name,
skill.description,
skill.directory,
skill.path,
skill.content_hash,
skill.enabled,
now_rfc3339()
],
).map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
pub(super) fn scan_skill_dir(
base: &Path,
enabled: bool,
source: &str,
out: &mut Vec<ManagedSkill>,
seen: &mut HashSet<String>,
) -> Result<()> {
if !base.exists() {
return Ok(());
}
for entry in fs::read_dir(base).map_err(|e| io_err(base, e))? {
let entry = entry.map_err(|e| io_err(base, e))?;
let path = entry.path();
if !path.is_dir() || !path.join("SKILL.md").is_file() {
continue;
}
let directory = entry.file_name().to_string_lossy().to_string();
let id = sanitize_dir_name(&directory, "skill");
if seen.contains(&id) {
continue;
}
let (name, description) = read_skill_metadata(&path, &directory);
let hash = compute_dir_hash(&path).ok();
out.push(ManagedSkill {
id: id.clone(),
name,
description,
directory,
enabled,
source: source.to_string(),
path: path.display().to_string(),
content_hash: hash,
update_status: "未检查".to_string(),
});
seen.insert(id);
}
Ok(())
}
pub(crate) fn sort_managed_skills(skills: &mut [ManagedSkill]) {
skills.sort_by(|a, b| {
a.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
.then_with(|| a.id.cmp(&b.id))
});
}
fn move_dir_replace(src: &Path, dst: &Path) -> Result<()> {
if !src.exists() {
return Ok(());
}
if dst.exists() {
fs::remove_dir_all(dst).map_err(|e| io_err(dst, e))?;
}
fs::rename(src, dst)
.or_else(|_| {
copy_dir_recursive(src, dst).map(|_| {
let _ = fs::remove_dir_all(src);
})
})
.map_err(|e| {
CodexxError::Config(format!(
"移动目录失败 {} -> {}: {e}",
src.display(),
dst.display()
))
})
}
pub(crate) fn toggle_codex_skill_inner(
config_dir: Option<String>,
id: String,
enabled: bool,
) -> Result<SkillsMcpState> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let skills_dir = codex_skills_dir(&codex_dir);
let disabled_dir = disabled_skills_dir()?;
ensure_directory(&skills_dir)?;
ensure_directory(&disabled_dir)?;
let current_state = build_skills_mcp_state_inner(config_dir.clone())?;
let name = current_state
.skills
.iter()
.find(|skill| skill.id == id)
.map(|skill| skill.directory.clone())
.unwrap_or_else(|| sanitize_dir_name(&id, "skill"));
let enabled_path = skills_dir.join(&name);
let disabled_path = disabled_dir.join(&name);
if enabled {
if disabled_path.exists() {
move_dir_replace(&disabled_path, &enabled_path)
.map_err(|e| CodexxError::Config(format!("启用 Skill 失败: {e}")))?;
}
} else if enabled_path.exists() {
move_dir_replace(&enabled_path, &disabled_path)
.map_err(|e| CodexxError::Config(format!("禁用 Skill 失败: {e}")))?;
}
build_skills_mcp_state_inner(config_dir)
}
pub(crate) fn install_skill_zip_inner(
config_dir: Option<String>,
file_name: String,
bytes: Vec<u8>,
) -> Result<SkillsMcpActionResult> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
let skills_dir = codex_skills_dir(&codex_dir);
ensure_directory(&skills_dir)?;
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
.map_err(|e| CodexxError::Config(format!("读取 ZIP 失败: {e}")))?;
let tmp = app_home()?
.join("tmp")
.join(format!("skill-zip-{}", Local::now().timestamp_millis()));
ensure_directory(&tmp)?;
let install_result = (|| -> Result<usize> {
let mut total_size = 0u64;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| CodexxError::Config(format!("读取 ZIP 条目失败: {e}")))?;
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else {
continue;
};
total_size += file.size();
if total_size > MAX_SKILL_ZIP_BYTES {
return Err(CodexxError::Config("ZIP 解压后超过 20MB".to_string()));
}
let out = tmp.join(path);
if file.name().ends_with('/') {
ensure_directory(&out)?;
} else {
if let Some(parent) = out.parent() {
ensure_directory(parent)?;
}
let mut outfile = fs::File::create(&out).map_err(|e| io_err(&out, e))?;
std::io::copy(&mut file, &mut outfile).map_err(|e| io_err(&out, e))?;
}
}
let mut skill_dirs = Vec::new();
fn find_skill_dirs(current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
if current.join("SKILL.md").is_file() {
out.push(current.to_path_buf());
return Ok(());
}
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
let entry = entry.map_err(|e| io_err(current, e))?;
let path = entry.path();
if path.is_dir() {
find_skill_dirs(&path, out)?;
}
}
Ok(())
}
find_skill_dirs(&tmp, &mut skill_dirs)?;
if skill_dirs.is_empty() {
return Err(CodexxError::Config("ZIP 中没有找到 SKILL.md".to_string()));
}
let mut imported_skills = 0usize;
for src in skill_dirs {
let fallback = file_name.trim_end_matches(".zip");
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
let (skill_name, _) = read_skill_metadata(&src, dir_name);
let dst_name = sanitize_dir_name(&skill_name, "skill");
let dst = skills_dir.join(dst_name);
if dst.exists() {
fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?;
}
copy_dir_recursive(&src, &dst)?;
imported_skills += 1;
}
Ok(imported_skills)
})();
let _ = fs::remove_dir_all(&tmp);
let imported_skills = install_result?;
let state = build_skills_mcp_state_inner(config_dir)?;
Ok(SkillsMcpActionResult {
imported_skills,
imported_mcp: 0,
message: format!("已从 ZIP 安装 {imported_skills} 个 Skill"),
state,
})
}
fn ccswitch_skill_meta_by_directory() -> Result<HashMap<String, CcSwitchSkillMeta>> {
let db = default_ccswitch_db_path()?;
if !db.exists() {
return Ok(HashMap::new());
}
let conn = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| {
CodexxError::Database(format!(
"打开 cc-switch Skills 数据库失败 {}: {e}",
db.display()
))
})?;
let mut stmt = match conn.prepare(
"SELECT directory, repo_owner, repo_name, repo_branch, content_hash FROM skills
WHERE repo_owner IS NOT NULL AND repo_name IS NOT NULL",
) {
Ok(stmt) => stmt,
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
if message.to_lowercase().contains("no such table") =>
{
return Ok(HashMap::new());
}
Err(e) => return Err(CodexxError::Database(e.to_string())),
};
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
))
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut out = HashMap::new();
for row in rows {
let (directory, owner, repo, branch, content_hash) =
row.map_err(|e| CodexxError::Database(e.to_string()))?;
let (Some(repo_owner), Some(repo_name)) = (owner, repo) else {
continue;
};
out.insert(
directory.to_ascii_lowercase(),
CcSwitchSkillMeta {
repo_owner,
repo_name,
repo_branch: branch.unwrap_or_else(|| "main".to_string()),
content_hash,
},
);
}
Ok(out)
}
fn download_repo_skill_hashes(
owner: &str,
repo: &str,
branch: &str,
) -> std::result::Result<HashMap<String, String>, String> {
use sha2::{Digest, Sha256};
const MAX_ZIP_BYTES: u64 = 100 * 1024 * 1024;
let url = format!("https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip");
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(18))
.build();
let response = agent
.get(&url)
.set("User-Agent", "Codex-X")
.call()
.map_err(|e| format!("下载 {owner}/{repo}@{branch} 失败: {e}"))?;
let mut bytes = Vec::new();
response
.into_reader()
.take(MAX_ZIP_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| format!("读取 {owner}/{repo}@{branch} ZIP 失败: {e}"))?;
if bytes.len() as u64 > MAX_ZIP_BYTES {
return Err(format!("{owner}/{repo}@{branch} ZIP 超过 100MB"));
}
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
.map_err(|e| format!("解析 {owner}/{repo}@{branch} ZIP 失败: {e}"))?;
let mut files = Vec::<(String, Vec<u8>)>::new();
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("读取 ZIP 条目失败: {e}"))?;
if file.is_dir() {
continue;
}
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else {
continue;
};
let normalized = path.to_string_lossy().replace('\\', "/");
if normalized
.split('/')
.any(|part| part.starts_with('.') && part != ".")
{
continue;
}
let mut data = Vec::new();
file.read_to_end(&mut data)
.map_err(|e| format!("读取 ZIP 文件失败: {e}"))?;
files.push((normalized, data));
}
let mut prefixes = HashMap::<String, String>::new();
for (path, _) in &files {
if !path.ends_with("/SKILL.md") && path != "SKILL.md" {
continue;
}
let Some(prefix) = path.strip_suffix("/SKILL.md") else {
continue;
};
let Some(name) = prefix.rsplit('/').next() else {
continue;
};
prefixes.insert(name.to_ascii_lowercase(), prefix.to_string());
}
let mut hashes = HashMap::new();
for (skill_name, prefix) in prefixes {
let prefix_with_slash = format!("{prefix}/");
let mut scoped = files
.iter()
.filter_map(|(path, data)| {
path.strip_prefix(&prefix_with_slash)
.map(|rel| (rel.to_string(), data.as_slice()))
})
.collect::<Vec<_>>();
scoped.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new();
for (rel, data) in scoped {
hasher.update(rel.as_bytes());
hasher.update(b"\0");
hasher.update(data);
hasher.update(b"\0");
}
hashes.insert(skill_name, format!("{:x}", hasher.finalize()));
}
Ok(hashes)
}
pub(crate) fn check_skill_updates_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
let mut next = build_skills_mcp_state_inner(config_dir)?;
let conn = open_db()?;
let ccswitch_meta = ccswitch_skill_meta_by_directory().unwrap_or_default();
let mut remote_hash_cache = HashMap::<
(String, String, String),
std::result::Result<HashMap<String, String>, String>,
>::new();
for skill in &mut next.skills {
let old: Option<String> = conn
.query_row(
"SELECT content_hash FROM managed_skills WHERE id = ?1",
[&skill.id],
|row| row.get(0),
)
.ok();
let local_status = match (&old, &skill.content_hash) {
(Some(a), Some(b)) if a != b => "本地有变化".to_string(),
(Some(_), Some(_)) => "已是最新".to_string(),
_ => "已记录".to_string(),
};
let meta = ccswitch_meta.get(&skill.directory.to_ascii_lowercase());
skill.update_status = if let Some(meta) = meta {
let key = (
meta.repo_owner.clone(),
meta.repo_name.clone(),
meta.repo_branch.clone(),
);
let remote = remote_hash_cache
.entry(key.clone())
.or_insert_with(|| download_repo_skill_hashes(&key.0, &key.1, &key.2));
match remote {
Ok(remote_hashes) => {
let remote_hash = remote_hashes.get(&skill.directory.to_ascii_lowercase());
let local_hash = skill.content_hash.as_ref().or(meta.content_hash.as_ref());
match (local_hash, remote_hash) {
(Some(local), Some(remote)) if local != remote => "有新版本".to_string(),
(Some(_), Some(_)) => "已是最新".to_string(),
(_, Some(_)) => "已记录远程".to_string(),
_ => "未找到远程目录".to_string(),
}
}
Err(e) => format!("远程检查失败:{e}"),
}
} else {
local_status
};
save_managed_skill(skill)?;
}
Ok(next)
}
@@ -0,0 +1,66 @@
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManagedMcpServer {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) transport: String,
pub(crate) enabled: bool,
pub(crate) source: String,
pub(crate) summary: String,
pub(crate) command: Option<String>,
pub(crate) url: Option<String>,
pub(crate) config_json: Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManagedSkill {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) directory: String,
pub(crate) enabled: bool,
pub(crate) source: String,
pub(crate) path: String,
pub(crate) content_hash: Option<String>,
pub(crate) update_status: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkillsMcpState {
pub(crate) codex_dir: String,
pub(crate) codex_skills_dir: String,
pub(crate) disabled_skills_dir: String,
pub(crate) skills: Vec<ManagedSkill>,
pub(crate) mcp_servers: Vec<ManagedMcpServer>,
pub(crate) warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkillsMcpActionResult {
pub(crate) imported_skills: usize,
pub(crate) imported_mcp: usize,
pub(crate) message: String,
pub(crate) state: SkillsMcpState,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SkillsMcpImportPreview {
pub(crate) skills: Vec<ManagedSkill>,
pub(crate) mcp_servers: Vec<ManagedMcpServer>,
pub(crate) warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub(super) struct CcSwitchSkillMeta {
pub(super) repo_owner: String,
pub(super) repo_name: String,
pub(super) repo_branch: String,
pub(super) content_hash: Option<String>,
}
@@ -0,0 +1,41 @@
use crate::error::{CodexxError, Result};
use rusqlite::Connection;
use std::collections::HashSet;
pub(crate) fn sql_select_column(cols: &HashSet<String>, name: &str, fallback: &str) -> String {
if cols.contains(name) {
format!("\"{}\"", name.replace('"', "\"\""))
} else {
fallback.to_string()
}
}
pub(crate) fn sqlite_has_table(conn: &Connection, table: &str) -> Result<bool> {
conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
[table],
|_| Ok(()),
)
.map(|_| true)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(false),
other => Err(CodexxError::Database(other.to_string())),
})
}
pub(crate) fn table_column_set(conn: &Connection, table: &str) -> Result<HashSet<String>> {
let mut stmt = conn
.prepare(&format!(
"PRAGMA table_info(\"{}\")",
table.replace('"', "\"\"")
))
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| CodexxError::Database(e.to_string()))?;
let mut cols = HashSet::new();
for row in rows {
cols.insert(row.map_err(|e| CodexxError::Database(e.to_string()))?);
}
Ok(cols)
}
+223
View File
@@ -0,0 +1,223 @@
use crate::backups::{latest_backup, BackupEntry};
use crate::config_migration::migrate_legacy_prompt_config;
use crate::error::Result;
use crate::file_io::{io_err, json_err, parse_toml_document, read_to_string_if_exists};
use crate::prompts::{
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
};
use crate::providers::{list_saved_providers_inner, SavedProvider};
use crate::{auth_path, config_path, string_value};
use serde::Serialize;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProviderSummary {
id: String,
name: Option<String>,
base_url: Option<String>,
wire_api: Option<String>,
requires_openai_auth: Option<bool>,
is_current: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CodexState {
codex_dir: String,
config_path: String,
auth_path: String,
config_exists: bool,
auth_exists: bool,
official_auth_available: bool,
pub(crate) model: Option<String>,
pub(crate) model_provider: Option<String>,
instruction_file: Option<String>,
pub(crate) instruction_enabled: bool,
pub(crate) instruction_injection_mode: Option<String>,
pub(crate) instruction_template_key: Option<String>,
agents_path: String,
active_saved_provider_id: Option<String>,
providers: Vec<ProviderSummary>,
pub(crate) config_text: String,
auth_preview: Option<Value>,
auth_text: String,
last_backup: Option<BackupEntry>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ActionResult {
pub(crate) ok: bool,
pub(crate) message: String,
pub(crate) backup_id: Option<String>,
pub(crate) state: CodexState,
}
fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
if !path.exists() {
return Ok(None);
}
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
if let Some(obj) = value.as_object_mut() {
for (key, val) in obj.iter_mut() {
let lower = key.to_ascii_lowercase();
if (lower.contains("key")
|| lower.contains("token")
|| lower.contains("secret")
|| lower.contains("password"))
&& val.as_str().is_some_and(|s| !s.trim().is_empty())
{
*val = Value::String("••••••••".to_string());
}
}
}
Ok(Some(value))
}
pub(crate) fn auth_has_material(path: &Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
let Some(obj) = value.as_object() else {
return Ok(false);
};
Ok(obj.iter().any(|(key, value)| {
if key == "auth_mode" {
return false;
}
match value {
Value::Null => false,
Value::String(s) => !s.trim().is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
_ => true,
}
}))
}
fn bool_from_item(item: Option<&Item>) -> Option<bool> {
item.and_then(|i| i.as_bool())
}
fn extract_providers(doc: &DocumentMut, current: Option<&str>) -> Vec<ProviderSummary> {
let Some(providers) = doc.get("model_providers").and_then(|i| i.as_table()) else {
return Vec::new();
};
providers
.iter()
.filter_map(|(id, item)| {
let table = item.as_table()?;
Some(ProviderSummary {
id: id.to_string(),
name: table
.get("name")
.and_then(|v| v.as_str())
.map(ToString::to_string),
base_url: table
.get("base_url")
.and_then(|v| v.as_str())
.map(ToString::to_string),
wire_api: table
.get("wire_api")
.and_then(|v| v.as_str())
.map(ToString::to_string),
requires_openai_auth: bool_from_item(table.get("requires_openai_auth")),
is_current: current.is_some_and(|c| c == id),
})
})
.collect()
}
fn normalized_provider_toml_for_match(text: &str) -> String {
text.replace("\r\n", "\n")
.replace('\r', "\n")
.lines()
.filter(|line| !line.trim_start().starts_with("experimental_bearer_token"))
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
pub(crate) fn active_saved_provider_id_from_config(
config_text: &str,
providers: &[SavedProvider],
) -> Option<String> {
let live = normalized_provider_toml_for_match(config_text);
if live.is_empty() {
return None;
}
let matches = providers
.iter()
.filter(|provider| {
provider
.toml_config
.as_deref()
.is_some_and(|toml| normalized_provider_toml_for_match(toml) == live)
})
.collect::<Vec<_>>();
(matches.len() == 1).then(|| matches[0].id.clone())
}
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
let cfg = config_path(&codex_dir);
let auth = auth_path(&codex_dir);
migrate_legacy_prompt_config(&codex_dir)?;
let text = read_to_string_if_exists(&cfg)?;
let doc = parse_toml_document(&cfg, &text)?;
let model = string_value(&doc, "model");
let model_provider = string_value(&doc, "model_provider");
let instruction_file = string_value(&doc, "model_instructions_file");
let model_template_key = instruction_file
.as_deref()
.map(prompt_template_key_for_instruction)
.transpose()?
.flatten();
let agents_template_key = managed_agents_template_key(&codex_dir)?;
let (instruction_injection_mode, instruction_template_key) =
if let Some(key) = agents_template_key {
(Some("append".to_string()), Some(key))
} else if let Some(key) = model_template_key {
(Some("replace".to_string()), Some(key))
} else {
(None, None)
};
let instruction_enabled = instruction_template_key.is_some();
let providers = extract_providers(&doc, model_provider.as_deref());
let active_saved_provider_id = if model_provider.as_deref() == Some("openai") {
None
} else {
active_saved_provider_id_from_config(&text, &list_saved_providers_inner()?)
};
Ok(CodexState {
codex_dir: codex_dir.display().to_string(),
config_path: cfg.display().to_string(),
auth_path: auth.display().to_string(),
config_exists: cfg.exists(),
auth_exists: auth.exists(),
official_auth_available: auth_has_material(&auth)?,
model,
model_provider,
instruction_file,
instruction_enabled,
instruction_injection_mode,
instruction_template_key,
agents_path: agents_path(&codex_dir).display().to_string(),
active_saved_provider_id,
providers,
config_text: text,
auth_preview: redacted_auth_preview(&auth)?,
auth_text: read_to_string_if_exists(&auth)?,
last_backup: latest_backup()?,
})
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
use crate::error::{CodexxError, Result};
use toml_edit::{DocumentMut, Item, Table};
pub(crate) fn string_value(doc: &DocumentMut, key: &str) -> Option<String> {
doc.get(key)
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
}
pub(crate) fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> Result<&'a mut Table> {
if !parent.contains_key(key) {
parent[key] = Item::Table(Table::new());
}
parent
.get_mut(key)
.and_then(|item| item.as_table_mut())
.ok_or_else(|| CodexxError::Config(format!("{key} 不是 TOML table")))
}
+246
View File
@@ -0,0 +1,246 @@
use crate::error::{CodexxError, Result};
#[cfg(test)]
use crate::remote::fetch_first_valid_with;
use crate::remote::{fetch_first_valid, RemoteSource};
use semver::Version;
use serde::{Deserialize, Serialize};
const RELEASES_URL: &str = "https://github.com/yynxxxxx/Codex-X/releases";
const SHIELDS_KEY: &str = "Shields CDN";
const GITHUB_KEY: &str = "GitHub Releases";
const RELEASE_SOURCES: [RemoteSource<'static>; 2] = [
RemoteSource::new(
SHIELDS_KEY,
"https://img.shields.io/github/v/release/yynxxxxx/Codex-X.json?display_name=tag",
Some("application/json"),
),
RemoteSource::new(
GITHUB_KEY,
"https://api.github.com/repos/yynxxxxx/Codex-X/releases/latest",
Some("application/vnd.github+json"),
),
];
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AppUpdateInfo {
latest_version: String,
html_url: String,
has_update: bool,
}
#[derive(Debug, Deserialize)]
struct ShieldsRelease {
value: Option<String>,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GithubRelease {
tag_name: String,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedRelease {
tag: String,
version: Version,
}
fn parse_release_tag(value: &str) -> Result<ParsedRelease> {
let tag = value.trim();
let version_text = tag.strip_prefix('v').unwrap_or(tag);
if tag.is_empty() || version_text.is_empty() {
return Err(CodexxError::Config("版本号为空".to_string()));
}
let version = Version::parse(version_text)
.map_err(|_| CodexxError::Config("返回的版本号无效".to_string()))?;
Ok(ParsedRelease {
tag: tag.to_string(),
version,
})
}
fn parse_shields_release(body: &str) -> Result<ParsedRelease> {
let release: ShieldsRelease = serde_json::from_str(body)
.map_err(|_| CodexxError::Config("CDN 版本信息无效".to_string()))?;
let tag = release
.value
.or(release.message)
.ok_or_else(|| CodexxError::Config("CDN 未返回版本号".to_string()))?;
parse_release_tag(&tag)
}
fn parse_github_release(body: &str) -> Result<ParsedRelease> {
let release: GithubRelease = serde_json::from_str(body)
.map_err(|_| CodexxError::Config("GitHub 版本信息无效".to_string()))?;
if release.draft || release.prerelease {
return Err(CodexxError::Config("GitHub 未返回正式版本".to_string()));
}
parse_release_tag(&release.tag_name)
}
fn parse_release_response(source: &RemoteSource<'_>, body: &str) -> Result<ParsedRelease> {
match source.key {
SHIELDS_KEY => parse_shields_release(body),
GITHUB_KEY => parse_github_release(body),
_ => Err(CodexxError::Config("未知版本来源".to_string())),
}
}
fn update_info(current_version: &str, release: ParsedRelease) -> Result<AppUpdateInfo> {
let current = Version::parse(current_version)
.map_err(|_| CodexxError::Config("当前软件版本无效".to_string()))?;
let html_url = format!("{RELEASES_URL}/tag/{}", release.tag);
Ok(AppUpdateInfo {
latest_version: release.tag,
html_url,
has_update: release.version > current,
})
}
#[cfg(test)]
fn check_app_update_with<Fetch>(current_version: &str, fetch: Fetch) -> Result<AppUpdateInfo>
where
Fetch: FnMut(&RemoteSource<'_>) -> Result<String>,
{
let release = fetch_first_valid_with(&RELEASE_SOURCES, fetch, parse_release_response)?;
update_info(current_version, release)
}
fn check_app_update_inner() -> Result<AppUpdateInfo> {
let release = fetch_first_valid(&RELEASE_SOURCES, parse_release_response)?;
update_info(env!("CARGO_PKG_VERSION"), release)
}
#[tauri::command]
pub(crate) async fn check_app_update() -> Result<AppUpdateInfo> {
tauri::async_runtime::spawn_blocking(check_app_update_inner)
.await
.map_err(|error| CodexxError::Config(format!("检查软件更新失败: {error}")))?
}
#[cfg(test)]
mod tests {
use super::*;
const SHIELDS_OK: &str = r#"{"value":"v0.2.36","message":"v0.2.36"}"#;
const GITHUB_OK: &str = r#"{"tag_name":"v0.2.36","draft":false,"prerelease":false}"#;
#[test]
fn cdn_success_does_not_call_github() {
let mut calls = Vec::new();
let info = check_app_update_with("0.2.35", |source| {
calls.push(source.key);
Ok(SHIELDS_OK.to_string())
})
.expect("CDN version is valid");
assert_eq!(calls, [SHIELDS_KEY]);
assert_eq!(info.latest_version, "v0.2.36");
assert_eq!(
info.html_url,
"https://github.com/yynxxxxx/Codex-X/releases/tag/v0.2.36"
);
assert!(info.has_update);
let json = serde_json::to_value(info).expect("serialize update info");
assert_eq!(json["latestVersion"], "v0.2.36");
assert_eq!(json["hasUpdate"], true);
assert!(json.get("latest_version").is_none());
}
#[test]
fn request_error_falls_back_to_github() {
let mut calls = Vec::new();
let info = check_app_update_with("0.2.35", |source| {
calls.push(source.key);
if source.key == SHIELDS_KEY {
Err(CodexxError::Config("offline".to_string()))
} else {
Ok(GITHUB_OK.to_string())
}
})
.expect("GitHub fallback succeeds");
assert_eq!(calls, [SHIELDS_KEY, GITHUB_KEY]);
assert!(info.has_update);
}
#[test]
fn empty_response_falls_back_to_github() {
let mut calls = Vec::new();
let info = check_app_update_with("0.2.35", |source| {
calls.push(source.key);
Ok(if source.key == SHIELDS_KEY {
" ".to_string()
} else {
GITHUB_OK.to_string()
})
})
.expect("GitHub fallback succeeds");
assert_eq!(calls, [SHIELDS_KEY, GITHUB_KEY]);
assert!(info.has_update);
}
#[test]
fn parse_failure_falls_back_to_github() {
let mut calls = Vec::new();
let info = check_app_update_with("0.2.35", |source| {
calls.push(source.key);
Ok(if source.key == SHIELDS_KEY {
r#"{"value":"no releases or repo not found"}"#.to_string()
} else {
GITHUB_OK.to_string()
})
})
.expect("GitHub fallback succeeds");
assert_eq!(calls, [SHIELDS_KEY, GITHUB_KEY]);
assert!(info.has_update);
}
#[test]
fn failure_from_every_source_is_reported() {
let error = check_app_update_with("0.2.35", |source| {
Err(CodexxError::Config(format!("{} unavailable", source.key)))
})
.expect_err("all sources fail");
let message = error.to_string();
assert!(message.contains(SHIELDS_KEY));
assert!(message.contains(GITHUB_KEY));
}
#[test]
fn parses_shields_and_github_release_payloads() {
assert_eq!(
parse_shields_release(SHIELDS_OK)
.expect("parse Shields")
.version,
Version::parse("0.2.36").unwrap()
);
assert_eq!(
parse_github_release(GITHUB_OK).expect("parse GitHub").tag,
"v0.2.36"
);
assert!(parse_shields_release(r#"{"value":"repo not found"}"#).is_err());
assert!(parse_github_release(r#"{"tag_name":"nightly"}"#).is_err());
}
#[test]
fn semver_comparison_handles_equal_older_and_prerelease_versions() {
let stable = parse_release_tag("v0.3.0").unwrap();
assert!(!update_info("0.3.0", stable.clone()).unwrap().has_update);
assert!(update_info("0.2.35", stable.clone()).unwrap().has_update);
assert!(!update_info("0.4.0", stable).unwrap().has_update);
let prerelease = parse_release_tag("v0.3.0-beta.1").unwrap();
assert!(!update_info("0.3.0", prerelease).unwrap().has_update);
let stable = parse_release_tag("v0.3.0").unwrap();
assert!(update_info("0.3.0-beta.1", stable).unwrap().has_update);
}
}
+14 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.31",
"version": "0.3.0",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
@@ -29,11 +29,12 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://api.github.com"
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -42,5 +43,16 @@
"icons/icon.ico"
],
"targets": "all"
},
"plugins": {
"updater": {
"endpoints": [
"https://github.com/yynxxxxx/Codex-X/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZCQTBEQkRFNTZCQjBBNUUKUldSZUNydFczdHVnYXkzSTM0N0F4UG5oV3BiVVJ0OEZwazdOd3hMRGRMNGJZaThoOEVneG9qb0cK",
"windows": {
"installMode": "passive"
}
}
}
}
+270
View File
@@ -0,0 +1,270 @@
import { useSyncExternalStore } from "react";
import {
check as checkForTauriUpdate,
type CheckOptions,
type DownloadEvent,
type DownloadOptions,
type Update,
} from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export type AppUpdaterPhase =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "ready"
| "error";
export type AppUpdaterFailure = "check" | "download" | "install" | "restart" | null;
export type AppUpdaterState = Readonly<{
phase: AppUpdaterPhase;
currentVersion: string | null;
latestVersion: string | null;
notes: string | null;
publishedAt: string | null;
downloadedBytes: number;
totalBytes: number | null;
failure: AppUpdaterFailure;
}>;
export type AppUpdaterCheckResult = "available" | "up-to-date" | "error";
export type AppUpdaterCheckOptions = CheckOptions & {
force?: boolean;
};
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
phase: "idle",
currentVersion: null,
latestVersion: null,
notes: null,
publishedAt: null,
downloadedBytes: 0,
totalBytes: null,
failure: null,
};
type Listener = () => void;
type RetryAction = "check" | "update" | "restart";
function normalizeByteCount(value: number | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
}
class AppUpdaterController {
private state: AppUpdaterState = INITIAL_APP_UPDATER_STATE;
private readonly listeners = new Set<Listener>();
private update: Update | null = null;
private checkPromise: Promise<AppUpdaterCheckResult> | null = null;
private updatePromise: Promise<AppUpdaterPhase> | null = null;
private restartPromise: Promise<AppUpdaterPhase> | null = null;
private retryAction: RetryAction = "check";
readonly getSnapshot = (): AppUpdaterState => this.state;
readonly subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
readonly check = (options: AppUpdaterCheckOptions = {}): Promise<AppUpdaterCheckResult> => {
if (this.checkPromise) return this.checkPromise;
if (this.isUpdateBusy()) {
return Promise.resolve(this.update ? "available" : "error");
}
if (this.update && !options.force) return Promise.resolve("available");
const { force: _force, ...pluginOptions } = options;
this.checkPromise = this.performCheck(pluginOptions).finally(() => {
this.checkPromise = null;
});
return this.checkPromise;
};
readonly downloadAndInstall = (options?: DownloadOptions): Promise<AppUpdaterPhase> => {
if (this.updatePromise) return this.updatePromise;
if (this.state.phase === "ready") return Promise.resolve("ready");
if (!this.update) {
this.retryAction = "check";
this.setState({ phase: "error", failure: "check" });
return Promise.resolve("error");
}
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => {
this.updatePromise = null;
});
return this.updatePromise;
};
readonly retry = (): Promise<AppUpdaterCheckResult | AppUpdaterPhase> => {
if (this.retryAction === "restart") return this.restart();
if (this.retryAction === "update" && this.update) return this.downloadAndInstall();
return this.check({ force: true });
};
readonly restart = (): Promise<AppUpdaterPhase> => {
if (this.restartPromise) return this.restartPromise;
if (this.state.phase !== "ready" && this.state.failure !== "restart") {
return Promise.resolve(this.state.phase);
}
this.restartPromise = this.performRestart().finally(() => {
this.restartPromise = null;
});
return this.restartPromise;
};
private isUpdateBusy(): boolean {
return this.state.phase === "downloading" || this.state.phase === "installing";
}
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
this.retryAction = "check";
this.setState({
phase: "checking",
downloadedBytes: 0,
totalBytes: null,
failure: null,
});
try {
const nextUpdate = await checkForTauriUpdate(options);
if (!nextUpdate) {
await this.replaceUpdate(null);
this.setState({
phase: "idle",
latestVersion: null,
notes: null,
publishedAt: null,
failure: null,
});
return "up-to-date";
}
await this.replaceUpdate(nextUpdate);
this.retryAction = "update";
this.setState({
phase: "available",
currentVersion: nextUpdate.currentVersion,
latestVersion: nextUpdate.version,
notes: nextUpdate.body?.trim() || null,
publishedAt: nextUpdate.date || null,
downloadedBytes: 0,
totalBytes: null,
failure: null,
});
return "available";
} catch {
await this.replaceUpdate(null);
this.setState({ phase: "error", failure: "check" });
return "error";
}
}
private async performDownloadAndInstall(update: Update, options?: DownloadOptions): Promise<AppUpdaterPhase> {
this.retryAction = "update";
let downloadFinished = false;
this.setState({
phase: "downloading",
downloadedBytes: 0,
totalBytes: null,
failure: null,
});
try {
await update.downloadAndInstall((event) => {
if (event.event === "Finished") downloadFinished = true;
this.handleDownloadEvent(event);
}, options);
this.setState({
phase: "ready",
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
failure: null,
});
return "ready";
} catch {
const failedDuringInstall = downloadFinished || this.state.phase === "installing";
this.setState({
phase: "error",
failure: failedDuringInstall ? "install" : "download",
});
return "error";
}
}
private handleDownloadEvent(event: DownloadEvent): void {
if (event.event === "Started") {
this.setState({
phase: "downloading",
downloadedBytes: 0,
totalBytes: normalizeByteCount(event.data.contentLength),
});
return;
}
if (event.event === "Progress") {
const nextBytes = this.state.downloadedBytes + Math.max(0, event.data.chunkLength);
this.setState({
downloadedBytes: this.state.totalBytes === null
? nextBytes
: Math.min(nextBytes, this.state.totalBytes),
});
return;
}
this.setState({
phase: "installing",
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
});
}
private async performRestart(): Promise<AppUpdaterPhase> {
this.retryAction = "restart";
this.setState({ failure: null });
try {
await relaunch();
return "ready";
} catch {
this.setState({ phase: "error", failure: "restart" });
return "error";
}
}
private async replaceUpdate(nextUpdate: Update | null): Promise<void> {
const previousUpdate = this.update;
this.update = nextUpdate;
if (!previousUpdate || previousUpdate === nextUpdate) return;
try {
await previousUpdate.close();
} catch (error) {
console.warn("Unable to release the previous updater resource", error);
}
}
private setState(patch: Partial<AppUpdaterState>): void {
this.state = { ...this.state, ...patch };
this.listeners.forEach((listener) => listener());
}
}
export const appUpdater = new AppUpdaterController();
export function useAppUpdater() {
const state = useSyncExternalStore(appUpdater.subscribe, appUpdater.getSnapshot, appUpdater.getSnapshot);
return {
state,
check: appUpdater.check,
downloadAndInstall: appUpdater.downloadAndInstall,
retry: appUpdater.retry,
restart: appUpdater.restart,
} as const;
}
+408
View File
@@ -0,0 +1,408 @@
import {
AlertCircle,
CheckCircle2,
Download,
Loader2,
RefreshCw,
RotateCcw,
Settings,
Sparkles,
} from "lucide-react";
import { INITIAL_APP_UPDATER_STATE, type AppUpdaterState } from "../appUpdater";
import type { Lang, StartupDiagnostics } from "../types";
import { Button, ModalShell } from "./ui";
export type AppToastProps = {
lang: Lang;
message: string;
error: string;
loading?: boolean;
onDismissMessage: () => void;
onDismissError: () => void;
};
export function AppToast({
lang,
message,
error,
loading = false,
onDismissMessage,
onDismissError,
}: AppToastProps) {
const activeText = error || message;
if (!activeText) return null;
const isError = Boolean(error);
const status = loading ? "loading" : isError ? "error" : "success";
const [firstLine, ...remainingLines] = activeText.split("\n");
const detail = remainingLines.join("\n").trim();
const dismiss = isError ? onDismissError : onDismissMessage;
return (
<div
key={`${status}:${activeText}`}
className={`cx-app-toast cx-app-toast--${status}`}
role={isError ? "alert" : "status"}
aria-live={isError ? "assertive" : "polite"}
onAnimationEnd={(event) => {
if (event.target !== event.currentTarget || event.animationName !== "cx-app-toast-exit") return;
dismiss();
}}
>
{loading
? <Loader2 className="cx-app-toast-loader" size={18} aria-hidden="true" />
: <span className="cx-app-toast-dot" aria-hidden="true" />}
<div className="cx-app-toast-copy">
<strong>{firstLine || (isError ? (lang === "zh" ? "操作失败" : "Action failed") : "Codex-X")}</strong>
{detail && <span>{detail}</span>}
</div>
</div>
);
}
export type UpdateDialogProps = {
open: boolean;
lang: Lang;
state?: AppUpdaterState;
currentVersion?: string | null;
latestVersion?: string | null;
onClose: () => void;
onDownload: () => void;
onUpdate?: () => void | Promise<unknown>;
onRetry?: () => void | Promise<unknown>;
onRestart?: () => void | Promise<unknown>;
};
function formatUpdateBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
export function UpdateDialog({
open,
lang,
state,
currentVersion,
latestVersion,
onClose,
onDownload,
onUpdate,
onRetry,
onRestart,
}: UpdateDialogProps) {
const isChinese = lang === "zh";
const updaterState = state ?? {
...INITIAL_APP_UPDATER_STATE,
phase: "available" as const,
currentVersion: currentVersion ?? null,
latestVersion: latestVersion ?? null,
};
const phase = updaterState.phase;
const isBusy = phase === "downloading" || phase === "installing";
const totalBytes = updaterState.totalBytes;
const hasKnownProgress = totalBytes !== null && totalBytes > 0;
const progress = totalBytes !== null && totalBytes > 0
? Math.min(100, Math.round((updaterState.downloadedBytes / totalBytes) * 100))
: null;
const copy = isChinese
? {
checkingTitle: "正在检查更新",
checkingDescription: "正在确认是否有新版本。",
availableTitle: "发现新版本",
availableDescription: onUpdate
? "可以直接在软件内完成更新,无需重新下载安装包。"
: "检测到新版本,可前往下载页获取对应平台的安装包。",
downloadingTitle: "正在下载更新",
downloadingDescription: "请保持 Codex-X 打开,下载完成后会自动安装。",
installingTitle: "正在安装更新",
installingDescription: "即将完成,请暂时不要关闭软件。",
readyTitle: "更新已准备好",
readyDescription: "重新启动 Codex-X 即可使用新版本。",
errorTitle: "更新没有完成",
errorDescription: updaterState.failure === "restart"
? "软件未能重新启动,请再试一次。"
: "请重试;如果仍然失败,也可以前往下载页更新。",
idleTitle: "当前已是最新版本",
idleDescription: "暂时没有可用的新版本。",
current: "当前版本",
latest: "新版本",
later: "稍后",
close: "关闭",
updateNow: "立即更新",
downloading: "正在下载",
installing: "正在安装",
restart: "重新启动",
retry: "重试",
downloadPage: "打开下载页",
releaseNotes: "本次更新",
}
: {
checkingTitle: "Checking for updates",
checkingDescription: "Checking whether a new version is available.",
availableTitle: "New version available",
availableDescription: onUpdate
? "Update directly in the app without downloading the installer again."
: "A new version is available from the download page for your platform.",
downloadingTitle: "Downloading update",
downloadingDescription: "Keep Codex-X open. Installation starts automatically after download.",
installingTitle: "Installing update",
installingDescription: "Almost done. Please keep the app open.",
readyTitle: "Update is ready",
readyDescription: "Restart Codex-X to use the new version.",
errorTitle: "Update did not finish",
errorDescription: updaterState.failure === "restart"
? "Codex-X could not restart. Please try again."
: "Try again, or use the download page if the problem continues.",
idleTitle: "Codex-X is up to date",
idleDescription: "There is no new version available right now.",
current: "Current",
latest: "New version",
later: "Later",
close: "Close",
updateNow: "Update now",
downloading: "Downloading",
installing: "Installing",
restart: "Restart",
retry: "Try again",
downloadPage: "Open download page",
releaseNotes: "What's new",
};
const title = phase === "checking"
? copy.checkingTitle
: phase === "available"
? copy.availableTitle
: phase === "downloading"
? copy.downloadingTitle
: phase === "installing"
? copy.installingTitle
: phase === "ready"
? copy.readyTitle
: phase === "error"
? copy.errorTitle
: copy.idleTitle;
const description = phase === "checking"
? copy.checkingDescription
: phase === "available"
? copy.availableDescription
: phase === "downloading"
? copy.downloadingDescription
: phase === "installing"
? copy.installingDescription
: phase === "ready"
? copy.readyDescription
: phase === "error"
? copy.errorDescription
: copy.idleDescription;
const handleClose = () => {
if (!isBusy) onClose();
};
const footer = phase === "available"
? (
<>
<Button variant="secondary" onClick={handleClose}>{copy.later}</Button>
<Button
icon={<Download size={16} />}
onClick={() => {
if (onUpdate) void onUpdate();
else onDownload();
}}
>
{onUpdate ? copy.updateNow : copy.downloadPage}
</Button>
</>
)
: phase === "ready"
? (
<Button icon={<RefreshCw size={16} />} onClick={() => void onRestart?.()}>
{copy.restart}
</Button>
)
: phase === "error"
? (
<>
<Button variant="secondary" icon={<Download size={16} />} onClick={onDownload}>
{copy.downloadPage}
</Button>
<Button icon={<RotateCcw size={16} />} onClick={() => void onRetry?.()}>
{copy.retry}
</Button>
</>
)
: isBusy
? (
<Button disabled icon={<Loader2 className="spin" size={16} />}>
{phase === "downloading" ? copy.downloading : copy.installing}
</Button>
)
: <Button variant="secondary" onClick={handleClose}>{copy.close}</Button>;
return (
<ModalShell
open={open}
onClose={handleClose}
size="sm"
title={title}
description={description}
closeLabel={copy.close}
closeOnBackdrop={!isBusy}
closeOnEscape={!isBusy}
showCloseButton={!isBusy}
className="cx-update-dialog"
footer={footer}
>
<div className={`cx-update-dialog-icon cx-update-dialog-icon--${phase}`} aria-hidden="true">
{phase === "checking" || isBusy
? <Loader2 className="spin" size={20} />
: phase === "ready"
? <CheckCircle2 size={20} />
: phase === "error"
? <AlertCircle size={20} />
: <Sparkles size={20} />}
</div>
<dl className="cx-update-version-grid">
<div><dt>{copy.current}</dt><dd>{updaterState.currentVersion || currentVersion || "-"}</dd></div>
<div><dt>{copy.latest}</dt><dd>{updaterState.latestVersion || latestVersion || "-"}</dd></div>
</dl>
{(phase === "downloading" || phase === "installing" || phase === "ready") && (
<div className="cx-update-progress" aria-live="polite">
<div className="cx-update-progress-copy">
<span>{phase === "downloading" ? copy.downloading : phase === "installing" ? copy.installing : copy.restart}</span>
<strong>
{phase === "downloading"
? hasKnownProgress
? `${progress}% · ${formatUpdateBytes(updaterState.downloadedBytes)} / ${formatUpdateBytes(totalBytes)}`
: updaterState.downloadedBytes > 0
? formatUpdateBytes(updaterState.downloadedBytes)
: "..."
: phase === "ready"
? "100%"
: "..."}
</strong>
</div>
<div
className={`cx-update-progress-track${progress === null && phase === "downloading" ? " cx-update-progress-track--indeterminate" : ""}`}
role="progressbar"
aria-label={phase === "downloading" ? copy.downloading : copy.installing}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={phase === "ready" ? 100 : progress ?? undefined}
>
<span style={{ width: phase === "ready" || phase === "installing" ? "100%" : progress === null ? "38%" : `${progress}%` }} />
</div>
</div>
)}
{updaterState.notes && phase !== "checking" && (
<section className="cx-update-notes">
<strong>{copy.releaseNotes}</strong>
<p>{updaterState.notes}</p>
</section>
)}
</ModalShell>
);
}
export type StartupWizardDialogProps = {
open: boolean;
closing: boolean;
lang: Lang;
diagnostics: StartupDiagnostics | null;
configDir: string;
loading: boolean;
onConfigDirChange: (value: string) => void;
onRecheck: () => void;
onSkip: () => void;
onOpenSettings: () => void;
onEnter: () => void;
};
export function StartupWizardDialog({
open,
closing,
lang,
diagnostics,
configDir,
loading,
onConfigDirChange,
onRecheck,
onSkip,
onOpenSettings,
onEnter,
}: StartupWizardDialogProps) {
const isChinese = lang === "zh";
if (!diagnostics) return null;
return (
<ModalShell
open={open}
onClose={onSkip}
size="lg"
title={isChinese ? "首次启动向导" : "First-run wizard"}
description={diagnostics.summary}
showCloseButton={false}
closeOnBackdrop={false}
closeOnEscape={false}
className={closing ? "cx-startup-dialog cx-startup-dialog--closing" : "cx-startup-dialog"}
footer={(
<>
<Button variant="ghost" onClick={onSkip}>{isChinese ? "跳过" : "Skip"}</Button>
<Button variant="secondary" icon={<Settings size={16} />} onClick={onOpenSettings}>{isChinese ? "去设置" : "Settings"}</Button>
<Button icon={<CheckCircle2 size={16} />} onClick={onEnter}>{isChinese ? "进入 Codex-X" : "Enter Codex-X"}</Button>
</>
)}
>
<div className="cx-startup-path-control">
<label htmlFor="cx-startup-codex-home">CODEX_HOME</label>
<input
id="cx-startup-codex-home"
value={configDir || diagnostics.codexDir}
onChange={(event) => onConfigDirChange(event.target.value)}
placeholder="~/.codex"
spellCheck={false}
/>
<Button
variant="secondary"
icon={<RefreshCw size={16} className={loading ? "spin" : undefined} />}
onClick={onRecheck}
disabled={loading}
>
{isChinese ? "重新检测" : "Recheck"}
</Button>
</div>
<div className="cx-startup-checks">
{diagnostics.items.map((item) => {
const isOk = item.status === "ok";
const isManual = item.status === "manual";
const statusText = isChinese
? item.message
: isOk
? "Detected"
: isManual
? "Manual selection required"
: "Not found";
return (
<article className={`cx-startup-check${isOk ? " cx-startup-check--ok" : isManual ? " cx-startup-check--manual" : ""}`} key={item.key}>
<div className="cx-startup-check-icon" aria-hidden="true">
{isOk ? <CheckCircle2 size={17} /> : <AlertCircle size={17} />}
</div>
<div>
<strong>{item.label}</strong>
<p>{statusText}</p>
{item.path && <code title={item.path}>{item.path}</code>}
</div>
</article>
);
})}
</div>
</ModalShell>
);
}
+216
View File
@@ -0,0 +1,216 @@
import React from "react";
import type { ReactNode } from "react";
import {
Blocks,
Download,
FileCode2,
History,
Info,
LayoutDashboard,
LoaderCircle,
Moon,
RotateCcw,
Settings,
Sparkles,
Sun,
TerminalSquare,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { AppUpdaterPhase } from "../appUpdater";
import { IconButton } from "./ui/IconButton";
export type AppLanguage = "zh" | "en";
export type AppTheme = "light" | "dark";
export type AppTab =
| "dashboard"
| "provider"
| "sessions"
| "skillsMcp"
| "instruction"
| "toml"
| "settings"
| "about";
type NavItem = {
id: AppTab;
icon: LucideIcon;
label: Record<AppLanguage, string>;
};
const NAV_ITEMS: readonly NavItem[] = [
{ id: "dashboard", icon: LayoutDashboard, label: { zh: "概览", en: "Overview" } },
{ id: "provider", icon: Zap, label: { zh: "供应商", en: "Providers" } },
{ id: "sessions", icon: History, label: { zh: "会话管理", en: "Sessions" } },
{ id: "skillsMcp", icon: Blocks, label: { zh: "技能和MCP", en: "Skills & MCP" } },
{ id: "instruction", icon: Sparkles, label: { zh: "指令提示词", en: "Prompts" } },
{ id: "toml", icon: FileCode2, label: { zh: "TOML", en: "TOML" } },
{ id: "settings", icon: Settings, label: { zh: "设置", en: "Settings" } },
{ id: "about", icon: Info, label: { zh: "关于", en: "About" } },
] as const;
export type AppShellProps = {
activeTab: AppTab;
onTabChange: (tab: AppTab) => void;
lang: AppLanguage;
theme: AppTheme;
onToggleTheme: () => void;
codexVersion?: string | null;
appVersion?: string | null;
hasUpdate?: boolean;
updatePhase?: AppUpdaterPhase;
onOpenUpdate?: () => void;
isMacRuntime?: boolean;
children: ReactNode;
sidebarFooter?: ReactNode;
className?: string;
contentClassName?: string;
};
export function AppShell({
activeTab,
onTabChange,
lang,
theme,
onToggleTheme,
codexVersion,
appVersion,
hasUpdate = false,
updatePhase = "idle",
onOpenUpdate,
isMacRuntime = false,
children,
sidebarFooter,
className,
contentClassName,
}: AppShellProps) {
const shellClassName = [
"cx-app-shell",
isMacRuntime ? "cx-app-shell--mac" : "",
className,
].filter(Boolean).join(" ");
const contentClasses = ["cx-app-content", contentClassName].filter(Boolean).join(" ");
const navigationLabel = lang === "zh" ? "主导航" : "Main navigation";
const codexVersionLabel = codexVersion || (lang === "zh" ? "未检测到" : "Not detected");
const themeLabel = lang === "zh" ? "外观模式" : "Appearance";
const themeValue = theme === "dark"
? (lang === "zh" ? "深色" : "Dark")
: (lang === "zh" ? "浅色" : "Light");
const toggleThemeLabel = theme === "dark"
? (lang === "zh" ? "切换为浅色模式" : "Switch to light mode")
: (lang === "zh" ? "切换为深色模式" : "Switch to dark mode");
const ThemeIcon = theme === "dark" ? Moon : Sun;
const updateActionState = updatePhase === "downloading"
|| updatePhase === "installing"
|| updatePhase === "ready"
|| updatePhase === "available"
? updatePhase
: hasUpdate
? "available"
: null;
const updateActionLabel = updateActionState === "downloading"
? (lang === "zh" ? "查看更新下载进度" : "View update download progress")
: updateActionState === "installing"
? (lang === "zh" ? "查看更新安装进度" : "View update installation progress")
: updateActionState === "ready"
? (lang === "zh" ? "打开更新窗口并重新启动" : "Open update window and restart")
: (lang === "zh" ? "查看并下载更新" : "View and download update");
const UpdateActionIcon = updateActionState === "downloading" || updateActionState === "installing"
? LoaderCircle
: updateActionState === "ready"
? RotateCcw
: Download;
return (
<div className={shellClassName}>
{isMacRuntime && (
<div className="cx-window-drag-region" data-tauri-drag-region aria-hidden="true" />
)}
<aside className="cx-sidebar">
<div className="cx-brand">
<div className="cx-brand-mark" aria-hidden="true">X</div>
<div className="cx-brand-copy">
<div className="cx-brand-title-row">
<h1>Codex-X</h1>
{appVersion && <span className="cx-app-version">v{appVersion.replace(/^v/i, "")}</span>}
</div>
<p>{lang === "zh" ? "切换 · 指令 · 配置" : "Switch · Instruct · Config"}</p>
</div>
{updateActionState && onOpenUpdate && (
<IconButton
className="cx-brand-update"
variant="ghost"
size="md"
icon={<UpdateActionIcon aria-hidden="true" />}
label={updateActionLabel}
title={updateActionLabel}
onClick={onOpenUpdate}
aria-busy={updateActionState === "downloading" || updateActionState === "installing"}
data-update-state={updateActionState}
/>
)}
</div>
<nav className="cx-sidebar-nav" aria-label={navigationLabel}>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
type="button"
className={`cx-nav-item${isActive ? " cx-nav-item--active" : ""}`}
onClick={() => React.startTransition(() => onTabChange(item.id))}
aria-current={isActive ? "page" : undefined}
title={item.label[lang]}
>
<span className="cx-nav-active-mark" aria-hidden="true" />
<Icon size={18} strokeWidth={1.9} aria-hidden="true" />
<span className="cx-nav-label">{item.label[lang]}</span>
</button>
);
})}
</nav>
<div className="cx-sidebar-footer">
{sidebarFooter}
<div className="cx-codex-version" title={`Codex CLI ${codexVersionLabel}`}>
<TerminalSquare size={17} strokeWidth={1.8} aria-hidden="true" />
<span>
<small>Codex CLI</small>
<strong>{codexVersionLabel}</strong>
</span>
</div>
<button
type="button"
className="cx-theme-button"
onClick={onToggleTheme}
role="switch"
aria-checked={theme === "dark"}
aria-label={toggleThemeLabel}
title={toggleThemeLabel}
>
<ThemeIcon size={17} strokeWidth={1.9} aria-hidden="true" />
<span className="cx-theme-copy">
<span>{themeLabel}</span>
<strong>{themeValue}</strong>
</span>
<span className="cx-theme-switch" aria-hidden="true">
<span className="cx-theme-switch-thumb" />
</span>
</button>
</div>
</aside>
<main className="cx-app-main">
<div className={contentClasses}>
<div className="cx-app-content-inner">{children}</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,95 @@
import React from "react";
import type { ReactNode } from "react";
const PAGE_TRANSITION_MS = 250;
type PageTransitionPhase = "idle" | "exit" | "enter";
export type PageTransitionProps = {
pageKey: string;
children: ReactNode;
};
function prefersReducedMotion() {
return typeof window !== "undefined"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function PageTransition({ pageKey, children }: PageTransitionProps) {
const [displayedKey, setDisplayedKey] = React.useState(pageKey);
const [phase, setPhase] = React.useState<PageTransitionPhase>("idle");
const [reducedMotion, setReducedMotion] = React.useState(prefersReducedMotion);
const lastDisplayedContent = React.useRef(children);
const pendingKey = React.useRef(pageKey);
pendingKey.current = pageKey;
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const handleChange = () => setReducedMotion(media.matches);
media.addEventListener("change", handleChange);
return () => media.removeEventListener("change", handleChange);
}, []);
React.useLayoutEffect(() => {
if (displayedKey === pageKey && phase !== "exit") {
lastDisplayedContent.current = children;
}
}, [children, displayedKey, pageKey, phase]);
React.useEffect(() => {
if (displayedKey === pageKey || phase === "exit") return;
if (reducedMotion) {
setDisplayedKey(pageKey);
setPhase("idle");
return;
}
setPhase("exit");
}, [displayedKey, pageKey, phase, reducedMotion]);
React.useEffect(() => {
if (phase !== "exit") return;
if (reducedMotion) {
setDisplayedKey(pendingKey.current);
setPhase("idle");
return;
}
const timer = window.setTimeout(() => {
setDisplayedKey(pendingKey.current);
setPhase("enter");
}, PAGE_TRANSITION_MS);
return () => window.clearTimeout(timer);
}, [phase, reducedMotion]);
React.useEffect(() => {
if (phase !== "enter") return;
if (reducedMotion) {
setPhase("idle");
return;
}
const timer = window.setTimeout(() => setPhase("idle"), PAGE_TRANSITION_MS);
return () => window.clearTimeout(timer);
}, [phase, reducedMotion]);
const displayedContent = displayedKey === pageKey
? children
: lastDisplayedContent.current;
return (
<div
className={`cx-page-transition cx-page-transition--${phase}`}
data-page={displayedKey}
aria-busy={phase !== "idle"}
>
{displayedContent}
</div>
);
}
@@ -0,0 +1,326 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Edit2,
FileText,
Plus,
Settings2,
Trash2,
} from "lucide-react";
import { PROMPT_CATEGORY_NAME_MAX_LENGTH } from "../promptCategories";
import type { PromptCategory } from "../promptCategories";
import type { Lang } from "../types";
import { Button, IconButton, ModalShell, cx } from "./ui";
export type PromptCategoryItem = {
key: string;
title: string;
};
type PromptCategoryManagerProps = {
open: boolean;
lang: Lang;
categories: PromptCategory[];
prompts: PromptCategoryItem[];
categoryForPrompt: (promptKey: string) => string;
onClose: () => void;
onAddCategory: (name: string) => boolean;
onRenameCategory: (categoryId: string, name: string) => boolean;
onDeleteCategory: (categoryId: string) => boolean;
onMovePrompt: (promptKey: string, categoryId: string) => void;
};
export function PromptCategoryManager({
open,
lang,
categories,
prompts,
categoryForPrompt,
onClose,
onAddCategory,
onRenameCategory,
onDeleteCategory,
onMovePrompt,
}: PromptCategoryManagerProps) {
const isChinese = lang === "zh";
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
const [editingCategoryName, setEditingCategoryName] = useState("");
const [isAddingCategory, setIsAddingCategory] = useState(false);
const [newCategoryName, setNewCategoryName] = useState("");
const [categoryToDelete, setCategoryToDelete] = useState<PromptCategory | null>(null);
const [validationError, setValidationError] = useState("");
useEffect(() => {
if (open) return;
setExpandedCategories(new Set());
setEditingCategoryId(null);
setEditingCategoryName("");
setIsAddingCategory(false);
setNewCategoryName("");
setCategoryToDelete(null);
setValidationError("");
}, [open]);
const promptsByCategory = useMemo(() => {
const grouped = new Map<string, PromptCategoryItem[]>();
for (const category of categories) grouped.set(category.id, []);
for (const prompt of prompts) grouped.get(categoryForPrompt(prompt.key))?.push(prompt);
return grouped;
}, [categories, categoryForPrompt, prompts]);
const copy = isChinese
? {
title: "分类管理",
deleteTitle: "删除提示词分类",
deleteDescription: (name: string) => `“${name}”中的提示词会移到默认分类,提示词本身不会被删除。`,
addCategory: "新增分类",
namePlaceholder: "输入分类名称",
cancel: "取消",
save: "保存",
add: "添加",
confirmDelete: "确认删除",
edit: "编辑分类",
remove: "删除分类",
keepOne: "至少保留一个分类",
empty: "该分类下暂无提示词",
move: (title: string) => `移动“${title}”到分类`,
duplicate: `分类名称不能为空、重复或超过 ${PROMPT_CATEGORY_NAME_MAX_LENGTH} 个字符`,
expand: (name: string) => `展开${name}`,
collapse: (name: string) => `收起${name}`,
}
: {
title: "Manage categories",
deleteTitle: "Delete prompt category",
deleteDescription: (name: string) => `Prompts in “${name}” will move to the default category. The prompts are not deleted.`,
addCategory: "Add category",
namePlaceholder: "Category name",
cancel: "Cancel",
save: "Save",
add: "Add",
confirmDelete: "Delete category",
edit: "Edit category",
remove: "Delete category",
keepOne: "Keep at least one category",
empty: "No prompts in this category",
move: (title: string) => `Move “${title}” to category`,
duplicate: `Category names cannot be empty, duplicated, or longer than ${PROMPT_CATEGORY_NAME_MAX_LENGTH} characters`,
expand: (name: string) => `Expand ${name}`,
collapse: (name: string) => `Collapse ${name}`,
};
const close = () => {
if (categoryToDelete) {
setCategoryToDelete(null);
return;
}
onClose();
};
const toggleExpanded = (categoryId: string) => {
setExpandedCategories((current) => {
const next = new Set(current);
if (next.has(categoryId)) next.delete(categoryId);
else next.add(categoryId);
return next;
});
};
const saveNewCategory = () => {
if (!onAddCategory(newCategoryName)) {
setValidationError(copy.duplicate);
return;
}
setIsAddingCategory(false);
setNewCategoryName("");
setValidationError("");
};
const saveCategoryName = (categoryId: string) => {
if (!onRenameCategory(categoryId, editingCategoryName)) {
setValidationError(copy.duplicate);
return;
}
setEditingCategoryId(null);
setEditingCategoryName("");
setValidationError("");
};
const footer = categoryToDelete ? (
<>
<Button variant="secondary" onClick={() => setCategoryToDelete(null)} data-initial-focus>{copy.cancel}</Button>
<Button
variant="danger"
icon={<Trash2 size={16} />}
onClick={() => {
if (onDeleteCategory(categoryToDelete.id)) setCategoryToDelete(null);
}}
>
{copy.confirmDelete}
</Button>
</>
) : undefined;
return (
<ModalShell
key={categoryToDelete ? "delete-category" : "manage-categories"}
open={open}
onClose={close}
title={categoryToDelete ? copy.deleteTitle : (
<span className="cx-prompt-category-modal-title"><Settings2 size={19} aria-hidden="true" />{copy.title}</span>
)}
description={categoryToDelete ? copy.deleteDescription(categoryToDelete.name) : undefined}
footer={footer}
size="md"
closeLabel={isChinese ? "关闭" : "Close"}
className={cx("cx-prompt-category-modal", categoryToDelete && "cx-prompt-category-modal--confirm")}
bodyClassName="cx-prompt-category-modal-body"
>
{categoryToDelete ? (
<div className="cx-prompt-category-delete-warning">
<AlertTriangle size={22} aria-hidden="true" />
<strong>{categoryToDelete.name}</strong>
</div>
) : (
<>
<div className="cx-prompt-category-list">
{categories.map((category) => {
const categoryPrompts = promptsByCategory.get(category.id) || [];
const expanded = expandedCategories.has(category.id);
const editing = editingCategoryId === category.id;
return (
<section className="cx-prompt-category-item" key={category.id}>
<div className="cx-prompt-category-row">
{editing ? (
<div className="cx-prompt-category-editor">
<input
value={editingCategoryName}
onChange={(event) => {
setEditingCategoryName(event.currentTarget.value);
setValidationError("");
}}
onKeyDown={(event) => {
if (event.key === "Enter") saveCategoryName(category.id);
if (event.key === "Escape") {
setEditingCategoryId(null);
setValidationError("");
}
}}
aria-label={copy.edit}
maxLength={PROMPT_CATEGORY_NAME_MAX_LENGTH}
autoFocus
/>
<button type="button" onClick={() => {
setEditingCategoryId(null);
setValidationError("");
}}>{copy.cancel}</button>
<button type="button" onClick={() => saveCategoryName(category.id)} disabled={!editingCategoryName.trim()}>{copy.save}</button>
</div>
) : (
<>
<button
type="button"
className="cx-prompt-category-expand"
onClick={() => toggleExpanded(category.id)}
aria-expanded={expanded}
aria-label={expanded ? copy.collapse(category.name) : copy.expand(category.name)}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<span>{category.name}</span>
<small>{categoryPrompts.length}</small>
</button>
<div className="cx-prompt-category-actions">
<IconButton
icon={<Edit2 size={15} />}
label={copy.edit}
variant="ghost"
size="sm"
onClick={() => {
setEditingCategoryId(category.id);
setEditingCategoryName(category.name);
setIsAddingCategory(false);
setValidationError("");
}}
/>
<IconButton
icon={<Trash2 size={15} />}
label={categories.length <= 1 ? copy.keepOne : copy.remove}
variant="ghost"
size="sm"
disabled={categories.length <= 1}
onClick={() => setCategoryToDelete(category)}
/>
</div>
</>
)}
</div>
{expanded && (
<div className="cx-prompt-category-prompts">
{categoryPrompts.length ? categoryPrompts.map((prompt) => (
<div className="cx-prompt-category-prompt" key={prompt.key}>
<FileText size={14} aria-hidden="true" />
<span title={prompt.title}>{prompt.title}</span>
<select
value={category.id}
onChange={(event) => onMovePrompt(prompt.key, event.currentTarget.value)}
aria-label={copy.move(prompt.title)}
>
{categories.map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
</select>
</div>
)) : <p className="cx-prompt-category-empty">{copy.empty}</p>}
</div>
)}
</section>
);
})}
{isAddingCategory && (
<div className="cx-prompt-category-new">
<input
value={newCategoryName}
onChange={(event) => {
setNewCategoryName(event.currentTarget.value);
setValidationError("");
}}
onKeyDown={(event) => {
if (event.key === "Enter") saveNewCategory();
if (event.key === "Escape") {
setIsAddingCategory(false);
setValidationError("");
}
}}
placeholder={copy.namePlaceholder}
maxLength={PROMPT_CATEGORY_NAME_MAX_LENGTH}
autoFocus
/>
<button type="button" onClick={() => {
setIsAddingCategory(false);
setValidationError("");
}}>{copy.cancel}</button>
<button type="button" onClick={saveNewCategory} disabled={!newCategoryName.trim()}>{copy.add}</button>
</div>
)}
</div>
{validationError && <p className="cx-prompt-category-error" role="alert">{validationError}</p>}
{!isAddingCategory && (
<button
type="button"
className="cx-prompt-category-add"
onClick={() => {
setIsAddingCategory(true);
setEditingCategoryId(null);
setNewCategoryName("");
setValidationError("");
}}
>
<Plus size={16} aria-hidden="true" />{copy.addCategory}
</button>
)}
</>
)}
</ModalShell>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cx } from "./utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
iconPosition?: "start" | "end";
}
export function Button({
variant = "primary",
size = "md",
icon,
iconPosition = "start",
className,
children,
...props
}: ButtonProps) {
return (
<button
type="button"
className={cx("ui-button", `ui-button--${variant}`, `ui-button--${size}`, className)}
{...props}
>
{iconPosition === "start" && icon}
{children}
{iconPosition === "end" && icon}
</button>
);
}
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
import type { InputHTMLAttributes, ReactNode } from "react";
import { Check, Minus } from "lucide-react";
import { cx } from "./utils";
export interface CheckboxProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "className"> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
label?: ReactNode;
description?: ReactNode;
indeterminate?: boolean;
className?: string;
inputClassName?: string;
}
export function Checkbox({
checked,
onCheckedChange,
label,
description,
indeterminate = false,
className,
inputClassName,
disabled,
...props
}: CheckboxProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<label className={cx("ui-checkbox", disabled && "ui-checkbox--disabled", className)}>
<input
{...props}
ref={inputRef}
type="checkbox"
checked={checked}
disabled={disabled}
aria-checked={indeterminate ? "mixed" : checked}
className={cx("ui-checkbox__input", inputClassName)}
onChange={(event) => onCheckedChange(event.target.checked)}
/>
<span
aria-hidden="true"
className={cx("ui-checkbox__box", checked && "ui-checkbox__box--checked", indeterminate && "ui-checkbox__box--indeterminate")}
>
{indeterminate ? <Minus size={13} strokeWidth={3} /> : <Check size={13} strokeWidth={3} />}
</span>
{(label || description) && (
<span className="ui-checkbox__content">
{label && <span className="ui-checkbox__label">{label}</span>}
{description && <span className="ui-checkbox__description">{description}</span>}
</span>
)}
</label>
);
}
@@ -0,0 +1,36 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cx } from "./utils";
export type IconButtonVariant = "neutral" | "primary" | "danger" | "ghost";
export type IconButtonSize = "sm" | "md" | "lg";
export interface IconButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "aria-label"> {
icon: ReactNode;
label: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
}
export function IconButton({
icon,
label,
variant = "neutral",
size = "md",
className,
title,
...props
}: IconButtonProps) {
return (
<button
type="button"
aria-label={label}
title={title ?? label}
className={cx("ui-icon-button", `ui-icon-button--${variant}`, `ui-icon-button--${size}`, className)}
{...props}
>
{icon}
</button>
);
}
@@ -0,0 +1,186 @@
import { useEffect, useId, useRef } from "react";
import type { MouseEvent, ReactNode, RefObject } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { IconButton } from "./IconButton";
import { cx } from "./utils";
export interface ModalShellProps {
open: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
description?: ReactNode;
footer?: ReactNode;
ariaLabel?: string;
size?: "sm" | "md" | "lg" | "xl";
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
initialFocusRef?: RefObject<HTMLElement>;
closeLabel?: string;
showCloseButton?: boolean;
className?: string;
bodyClassName?: string;
}
const focusableSelector = [
"button:not([disabled])",
"[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
let scrollLockCount = 0;
let previousBodyOverflow = "";
let previousBodyPaddingRight = "";
function lockBodyScroll() {
if (scrollLockCount === 0) {
previousBodyOverflow = document.body.style.overflow;
previousBodyPaddingRight = document.body.style.paddingRight;
const scrollbarGap = window.innerWidth - document.documentElement.clientWidth;
const bodyPaddingRight = Number.parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
document.body.style.overflow = "hidden";
if (scrollbarGap > 0) document.body.style.paddingRight = `${bodyPaddingRight + scrollbarGap}px`;
}
scrollLockCount += 1;
return () => {
scrollLockCount = Math.max(0, scrollLockCount - 1);
if (scrollLockCount === 0) {
document.body.style.overflow = previousBodyOverflow;
document.body.style.paddingRight = previousBodyPaddingRight;
}
};
}
function focusElement(element: HTMLElement | null) {
if (!element) return;
element.focus({ preventScroll: true });
}
export function ModalShell({
open,
onClose,
title,
children,
description,
footer,
ariaLabel,
size = "md",
closeOnBackdrop = true,
closeOnEscape = true,
initialFocusRef,
closeLabel = "关闭",
showCloseButton = true,
className,
bodyClassName,
}: ModalShellProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
if (!open) return;
const restoreTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const unlockBodyScroll = lockBodyScroll();
const frame = window.requestAnimationFrame(() => {
const dialog = dialogRef.current;
if (!dialog) return;
focusElement(initialFocusRef?.current || dialog.querySelector<HTMLElement>("[data-initial-focus]") || dialog);
});
return () => {
window.cancelAnimationFrame(frame);
unlockBodyScroll();
if (restoreTarget?.isConnected) {
window.requestAnimationFrame(() => focusElement(restoreTarget));
}
};
}, [open]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
const dialog = dialogRef.current;
if (!dialog) return;
if (event.key === "Escape" && closeOnEscape) {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter(
(element) => element.getClientRects().length > 0,
);
if (!focusable.length) {
event.preventDefault();
focusElement(dialog);
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) {
event.preventDefault();
focusElement(last);
} else if (!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))) {
event.preventDefault();
focusElement(first);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [closeOnEscape, onClose, open]);
if (!open) return null;
const handleBackdropClick = (event: MouseEvent<HTMLDivElement>) => {
if (closeOnBackdrop && event.target === event.currentTarget) onClose();
};
return createPortal(
<div className="ui-modal-backdrop" onClick={handleBackdropClick} data-modal-backdrop>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : titleId}
aria-describedby={description ? descriptionId : undefined}
tabIndex={-1}
className={cx("ui-modal", `ui-modal--${size}`, className)}
data-modal-dialog
>
<header className="ui-modal__header">
<div className="ui-modal__heading">
<h2 id={titleId} className="ui-modal__title">{title}</h2>
{description && <p id={descriptionId} className="ui-modal__description">{description}</p>}
</div>
{showCloseButton && (
<IconButton
icon={<X size={17} strokeWidth={2} />}
label={closeLabel}
variant="ghost"
size="sm"
onClick={onClose}
className="ui-modal__close"
/>
)}
</header>
<div className={cx("ui-modal__body", bodyClassName)}>{children}</div>
{footer && <footer className="ui-modal__footer">{footer}</footer>}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cx } from "./utils";
export type StatusBadgeTone = "neutral" | "info" | "success" | "warning" | "danger" | "accent";
export type StatusBadgeSize = "sm" | "md";
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: StatusBadgeTone;
size?: StatusBadgeSize;
dot?: boolean;
children: ReactNode;
}
export function StatusBadge({
tone = "neutral",
size = "sm",
dot = true,
className,
children,
...props
}: StatusBadgeProps) {
return (
<span className={cx("ui-status-badge", `ui-status-badge--${tone}`, `ui-status-badge--${size}`, className)} {...props}>
{dot && <span aria-hidden="true" className="ui-status-badge__dot" />}
<span>{children}</span>
</span>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { cx } from "./utils";
export interface ToggleProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "className" | "role"> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
label?: ReactNode;
description?: ReactNode;
className?: string;
inputClassName?: string;
}
export function Toggle({
checked,
onCheckedChange,
label,
description,
className,
inputClassName,
disabled,
...props
}: ToggleProps) {
return (
<label className={cx("ui-toggle", disabled && "ui-toggle--disabled", className)}>
<input
{...props}
type="checkbox"
role="switch"
checked={checked}
disabled={disabled}
aria-checked={checked}
className={cx("ui-toggle__input", inputClassName)}
onChange={(event) => onCheckedChange(event.target.checked)}
/>
<span aria-hidden="true" className="ui-toggle__track" />
{(label || description) && (
<span className="ui-toggle__content">
{label && <span className="ui-toggle__label">{label}</span>}
{description && <span className="ui-toggle__description">{description}</span>}
</span>
)}
</label>
);
}
+13
View File
@@ -0,0 +1,13 @@
export { Button } from "./Button";
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
export { IconButton } from "./IconButton";
export type { IconButtonProps, IconButtonSize, IconButtonVariant } from "./IconButton";
export { Checkbox } from "./Checkbox";
export type { CheckboxProps } from "./Checkbox";
export { Toggle } from "./Toggle";
export type { ToggleProps } from "./Toggle";
export { StatusBadge } from "./StatusBadge";
export type { StatusBadgeProps, StatusBadgeSize, StatusBadgeTone } from "./StatusBadge";
export { ModalShell } from "./ModalShell";
export type { ModalShellProps } from "./ModalShell";
export { cx } from "./utils";
+3
View File
@@ -0,0 +1,3 @@
export function cx(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
+1187 -1600
View File
File diff suppressed because it is too large Load Diff
+257
View File
@@ -0,0 +1,257 @@
import {
ArrowUpRight,
CheckCircle2,
Code2,
FileText,
KeyRound,
RefreshCw,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import "../styles/overview-page.css";
export type OverviewLanguage = "zh" | "en";
export type OverviewPageProps = {
lang: OverviewLanguage;
model?: string | null;
configDir: string;
resolvedCodexDir: string;
configExists: boolean;
providerLabel?: string | null;
instructionEnabled: boolean;
authExists: boolean;
configPath?: string | null;
modelProvider?: string | null;
instructionPath?: string | null;
loading: boolean;
hasUpdate: boolean;
latestVersion?: string | null;
onConfigDirChange: (value: string) => void;
onRefresh: () => void;
onOpenUpdate: () => void;
};
type StatusCardProps = {
icon: LucideIcon;
label: string;
value: string;
tone: "success" | "active" | "muted";
};
function StatusCard({ icon: Icon, label, value, tone }: StatusCardProps) {
return (
<article className={`cx-overview-status-card cx-overview-status-card--${tone}`}>
<div className="cx-overview-status-icon" aria-hidden="true">
<Icon size={19} strokeWidth={1.9} />
</div>
<div className="cx-overview-status-copy">
<span>{label}</span>
<strong title={value}>{value}</strong>
</div>
</article>
);
}
type ConfigRowProps = {
label: string;
value: string;
};
function ConfigRow({ label, value }: ConfigRowProps) {
return (
<div className="cx-overview-config-row">
<span>{label}</span>
<code title={value}>{value}</code>
</div>
);
}
export function OverviewPage({
lang,
model,
configDir,
resolvedCodexDir,
configExists,
providerLabel,
instructionEnabled,
authExists,
configPath,
modelProvider,
instructionPath,
loading,
hasUpdate,
latestVersion,
onConfigDirChange,
onRefresh,
onOpenUpdate,
}: OverviewPageProps) {
const isChinese = lang === "zh";
const text = isChinese
? {
eyebrow: "CODEX 配置管理器",
notConfigured: "未配置",
modelMissing: "未配置模型",
codexHome: "CODEX_HOME",
directoryPlaceholder: "留空使用默认目录",
load: "加载",
config: "配置文件",
found: "已找到",
missing: "未找到",
provider: "供应商",
official: "官方配置",
instruction: "指令提示词",
enabled: "已启用",
disabled: "未启用",
auth: "认证文件",
authFile: "auth.json",
noAuth: "未找到",
updateFound: "发现新版本",
updateAvailable: (version: string) => `Codex-X ${version} 已发布`,
viewUpdate: "查看更新",
liveStatus: "实时状态",
currentConfig: "当前 Codex 配置",
on: "提示词已启用",
off: "提示词未启用",
directory: "目录",
configPath: "配置",
model: "模型",
providerName: "供应商标识",
instructionFile: "指令文件",
}
: {
eyebrow: "CODEX CONFIG MANAGER",
notConfigured: "Not configured",
modelMissing: "Model not configured",
codexHome: "CODEX_HOME",
directoryPlaceholder: "Leave empty for the default directory",
load: "Load",
config: "Config file",
found: "Found",
missing: "Not found",
provider: "Provider",
official: "Official",
instruction: "Instructions",
enabled: "Enabled",
disabled: "Disabled",
auth: "Auth file",
authFile: "auth.json",
noAuth: "Not found",
updateFound: "New version available",
updateAvailable: (version: string) => `Codex-X ${version} is available`,
viewUpdate: "View update",
liveStatus: "LIVE STATUS",
currentConfig: "Current Codex configuration",
on: "Instructions enabled",
off: "Instructions disabled",
directory: "Directory",
configPath: "Config",
model: "Model",
providerName: "Provider",
instructionFile: "Instruction file",
};
const displayModel = model?.trim() || text.modelMissing;
const displayProvider = providerLabel?.trim() || modelProvider?.trim() || text.official;
const displayModelProvider = modelProvider?.trim() || text.notConfigured;
const displayDirectory = resolvedCodexDir.trim() || configDir.trim() || text.notConfigured;
const displayConfigPath = configPath?.trim() || text.notConfigured;
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
const updateVersion = latestVersion?.trim() || "";
const homeInputValue = configDir || resolvedCodexDir;
return (
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
<header className="cx-overview-header">
<div className="cx-overview-heading">
<p className="cx-overview-eyebrow">
<span className="cx-overview-live-dot" aria-hidden="true" />
{text.eyebrow}
</p>
<h2 title={displayModel}>{displayModel}</h2>
</div>
<div className="cx-overview-home-control">
<label htmlFor="cx-overview-codex-home">{text.codexHome}</label>
<input
id="cx-overview-codex-home"
type="text"
value={homeInputValue}
onChange={(event) => onConfigDirChange(event.target.value)}
placeholder={text.directoryPlaceholder}
spellCheck={false}
aria-label={text.codexHome}
/>
<button type="button" onClick={onRefresh} disabled={loading}>
<RefreshCw size={15} strokeWidth={2} className={loading ? "cx-overview-spin" : undefined} aria-hidden="true" />
{text.load}
</button>
</div>
</header>
{hasUpdate && (
<aside className="cx-overview-update-strip" role="status">
<div className="cx-overview-update-copy">
<span className="cx-overview-update-dot" aria-hidden="true" />
<div>
<strong>{text.updateFound}</strong>
{updateVersion && <p>{text.updateAvailable(updateVersion)}</p>}
</div>
</div>
<button type="button" onClick={onOpenUpdate}>
{text.viewUpdate}
<ArrowUpRight size={15} strokeWidth={2} aria-hidden="true" />
</button>
</aside>
)}
<div className="cx-overview-status-grid">
<StatusCard
icon={FileText}
label={text.config}
value={configExists ? text.found : text.missing}
tone={configExists ? "success" : "muted"}
/>
<StatusCard
icon={Code2}
label={text.provider}
value={displayProvider}
tone={modelProvider ? "active" : "muted"}
/>
<StatusCard
icon={Sparkles}
label={text.instruction}
value={instructionEnabled ? text.enabled : text.disabled}
tone={instructionEnabled ? "success" : "muted"}
/>
<StatusCard
icon={KeyRound}
label={text.auth}
value={authExists ? text.authFile : text.noAuth}
tone={authExists ? "success" : "muted"}
/>
</div>
<section className="cx-overview-config-panel">
<div className="cx-overview-panel-heading">
<div>
<p className="cx-overview-section-label">{text.liveStatus}</p>
<h3>{text.currentConfig}</h3>
</div>
<span className={`cx-overview-instruction-pill${instructionEnabled ? " cx-overview-instruction-pill--active" : ""}`}>
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
{instructionEnabled ? text.on : text.off}
</span>
</div>
<div className="cx-overview-config-list">
<ConfigRow label={text.directory} value={displayDirectory} />
<ConfigRow label={text.configPath} value={displayConfigPath} />
<ConfigRow label={text.model} value={displayModel} />
<ConfigRow label={text.providerName} value={displayModelProvider} />
<ConfigRow label={text.instructionFile} value={displayInstructionPath} />
</div>
</section>
</section>
);
}
+715
View File
@@ -0,0 +1,715 @@
import { useId, useMemo, useState } from "react";
import type { ChangeEvent, RefObject } from "react";
import {
ArrowLeft,
ArrowLeftRight,
CircleHelp,
CirclePlus,
FileText,
Loader2,
PencilLine,
Plus,
RefreshCw,
Save,
Settings2,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import { PageTransition } from "../components/PageTransition";
import { PromptCategoryManager } from "../components/PromptCategoryManager";
import type { PromptCategoryItem } from "../components/PromptCategoryManager";
import { Button, IconButton, StatusBadge, Toggle, cx } from "../components/ui";
import { usePromptCategories } from "../promptCategories";
import type {
BuiltinPromptStatus,
InstructionMode,
InstructionTemplate,
Lang,
PromptInjectionMode,
SavedPrompt,
} from "../types";
import "../styles/skills-prompts-pages.css";
type MaybeAsyncAction = () => void | Promise<void>;
export type ExternalPromptItem = {
title: string;
description: string;
filename?: string | null;
};
export type OrphanedBuiltinPrompt = {
id: string;
title: string;
description?: string | null;
};
export type PromptFormField = "title" | "filename" | "content";
export type PromptsPageProps = {
lang: Lang;
instructionMode: InstructionMode;
promptForm: SavedPrompt;
editingPromptId: string | null;
loading: boolean;
actionBusy: string;
promptSyncing: boolean;
promptCatalogReady: boolean;
promptImportRef: RefObject<HTMLInputElement>;
promptInjectionMode: PromptInjectionMode;
promptModeHelpOpen: boolean;
promptModeHelpRef: RefObject<HTMLDivElement>;
instructionEnabled: boolean;
activeInstructionTitle: string;
activeInjectionMode?: PromptInjectionMode;
instructionTemplates: InstructionTemplate[];
builtinPromptStatuses: BuiltinPromptStatus[];
activeBuiltinTemplateId?: string | null;
orphanedBuiltinPrompt?: OrphanedBuiltinPrompt | null;
savedPrompts: SavedPrompt[];
managedSavedPromptId?: string | null;
preservedSavedPromptFilename?: string | null;
externalPrompt?: ExternalPromptItem | null;
className?: string;
onSyncBuiltinPrompts: MaybeAsyncAction;
onImportPrompt: (file?: File | null) => void | Promise<void>;
onAddPrompt: () => void;
onInstructionModeChange: (mode: InstructionMode) => void;
onPromptInjectionModeChange: (mode: PromptInjectionMode) => void;
onTogglePromptModeHelp: () => void;
onEnableBuiltinPrompt: (id: string) => void | Promise<void>;
onDisableInstruction: MaybeAsyncAction;
onEnableSavedPrompt: (id: string) => void | Promise<void>;
onDisableExternalPrompt: MaybeAsyncAction;
onEditPrompt: (prompt: SavedPrompt) => void;
onDeletePrompt: (id: string) => void | Promise<void>;
onPromptFormFieldChange: (field: PromptFormField, value: string) => void;
onSavePrompt: MaybeAsyncAction;
};
function getCopy(lang: Lang) {
return lang === "zh"
? {
eyebrow: "PROMPT INJECTION",
title: "一键管理指令提示词",
description: "选择启用方式,再管理内置、在线或自定义的 Markdown 提示词。",
sync: "同步 GitHub 模板",
syncing: "同步中...",
importMd: "导入 md",
importing: "导入中...",
add: "添加提示词",
manageCategories: "分类管理",
emptyCategory: "该分类下暂无提示词",
currentStatus: "当前状态",
noActive: "未启用提示词",
keepExisting: "保留原提示词",
replaceExisting: "替换原提示词",
appendMode: "追加到 AGENTS.md",
replaceMode: "替换指令文件",
appendDetail: "当前模板写入 AGENTS.md,同时保留已有指令文件。",
replaceDetail: "当前模板通过 model_instructions_file 独立加载。",
inactiveDetail: "先选择启用方式,再打开下方任一模板。",
enableMethod: "启用方式",
helpLabel: "查看启用方式说明",
appendHelp: "只在 AGENTS.md 中增加 Codex-X 管理区块,不改动原有 model_instructions_file,适合叠加使用。",
replaceHelp: "当前模板会成为唯一生效的指令入口,原有 model_instructions_file 将被替换。",
pendingMode: (mode: string) => `当前模式不变,下次启用将使用“${mode}”。`,
modeHint: "点击模板开关时,使用这里选择的方式。",
keepTitle: "写入 AGENTS.md,并保留现有 model_instructions_file",
replaceTitle: "使用 model_instructions_file 替换现有指令文件",
enable: "启用",
disable: "关闭",
disableExternal: "禁用外部提示词",
current: "当前",
onlineRemoved: "在线已移除",
removedDescription: "该模板已从在线目录移除,当前配置仍在使用。",
customDescription: "自定义指令提示词",
preservedDescription: "用户原有提示词,追加模式下继续生效。",
existingPrompt: "用户原有指令提示词",
edit: "编辑",
remove: "删除",
formEyebrow: "CUSTOM PROMPT",
addFormTitle: "添加提示词",
editFormTitle: "编辑提示词",
formDescription: "保存为 Markdown 文件,之后可在列表中单独启用。",
back: "返回",
promptDetails: "提示词详情",
promptName: "提示词名称",
promptNamePlaceholder: "例如:通用编程助手、代码审查专家",
filename: "文件名",
filenamePlaceholder: "my-prompt.md",
content: "提示词内容",
contentHint: "Markdown",
contentPlaceholder: "在此输入提示词内容...",
save: "保存",
}
: {
eyebrow: "PROMPT INJECTION",
title: "Manage instruction prompts",
description: "Choose an activation method, then manage bundled, online, or custom Markdown prompts.",
sync: "Sync GitHub templates",
syncing: "Syncing...",
importMd: "Import md",
importing: "Importing...",
add: "Add prompt",
manageCategories: "Manage categories",
emptyCategory: "No prompts in this category",
currentStatus: "Current status",
noActive: "No prompt enabled",
keepExisting: "Keep existing",
replaceExisting: "Replace existing",
appendMode: "Append to AGENTS.md",
replaceMode: "Replace instruction file",
appendDetail: "The current template is written to AGENTS.md while the existing instruction file is preserved.",
replaceDetail: "The current template is loaded independently through model_instructions_file.",
inactiveDetail: "Choose an activation method, then turn on a template below.",
enableMethod: "Enable method",
helpLabel: "Show activation method help",
appendHelp: "Adds a Codex-X managed block to AGENTS.md without changing the existing model_instructions_file.",
replaceHelp: "Makes the selected template the only instruction entry and replaces the existing model_instructions_file.",
pendingMode: (mode: string) => `The current mode is unchanged. The next enable uses “${mode}”.`,
modeHint: "This method is used when a template is turned on.",
keepTitle: "Write to AGENTS.md and preserve model_instructions_file",
replaceTitle: "Replace the existing instruction file through model_instructions_file",
enable: "Enable",
disable: "Disable",
disableExternal: "Disable external prompt",
current: "Current",
onlineRemoved: "Removed online",
removedDescription: "This template was removed online but is still active.",
customDescription: "Custom instruction prompt",
preservedDescription: "Existing user prompt preserved by append mode.",
existingPrompt: "Existing user prompt",
edit: "Edit",
remove: "Delete",
formEyebrow: "CUSTOM PROMPT",
addFormTitle: "Add prompt",
editFormTitle: "Edit prompt",
formDescription: "Save it as Markdown, then enable it separately from the list.",
back: "Back",
promptDetails: "Prompt details",
promptName: "Prompt name",
promptNamePlaceholder: "For example: General coding assistant",
filename: "Filename",
filenamePlaceholder: "my-prompt.md",
content: "Prompt content",
contentHint: "Markdown",
contentPlaceholder: "Enter prompt content here...",
save: "Save",
};
}
function run(action: MaybeAsyncAction) {
void action();
}
function promptCategoryKey(kind: "builtin" | "saved" | "external", id: string) {
return `${kind}:${id.trim().toLowerCase()}`;
}
function savedPromptCategoryKey(prompt: SavedPrompt) {
return prompt.id.startsWith("external-")
? promptCategoryKey("external", prompt.filename)
: promptCategoryKey("saved", prompt.id);
}
type PromptRowProps = {
title: string;
description: string;
enabled: boolean;
loading: boolean;
toggleLabel: string;
onToggle: () => void | Promise<void>;
children?: React.ReactNode;
actions?: React.ReactNode;
};
function PromptRow({
title,
description,
enabled,
loading,
toggleLabel,
onToggle,
children,
actions,
}: PromptRowProps) {
return (
<article className="cx-prompts-row">
<div className="cx-prompts-row-head">
<div className="cx-prompts-row-heading">
<div className="cx-prompts-row-icon" aria-hidden="true"><FileText size={16} strokeWidth={1.9} /></div>
<div className="cx-prompts-row-title" title={title}><strong>{title}</strong></div>
</div>
<div className="cx-prompts-row-actions">
<Toggle
checked={enabled}
onCheckedChange={() => void onToggle()}
disabled={loading}
aria-label={toggleLabel}
/>
</div>
</div>
<div className="cx-prompts-row-copy">
<p title={description}>{description}</p>
</div>
{(children || actions) && (
<div className="cx-prompts-row-footer">
<div className="cx-prompts-row-details">{children}</div>
{actions}
</div>
)}
</article>
);
}
function PromptFormView({
lang,
promptForm,
editingPromptId,
loading,
onInstructionModeChange,
onPromptFormFieldChange,
onSavePrompt,
}: Pick<
PromptsPageProps,
| "lang"
| "promptForm"
| "editingPromptId"
| "loading"
| "onInstructionModeChange"
| "onPromptFormFieldChange"
| "onSavePrompt"
>) {
const copy = getCopy(lang);
const titleId = useId();
const filenameId = useId();
const contentId = useId();
return (
<div className="cx-prompts-form-page">
<header className="cx-prompts-header cx-prompts-form-header">
<div className="cx-prompts-heading">
<p><PencilLine size={14} aria-hidden="true" />{copy.formEyebrow}</p>
<h2>{editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
<span>{copy.formDescription}</span>
</div>
<Button
variant="secondary"
icon={<ArrowLeft />}
onClick={() => onInstructionModeChange("list")}
disabled={loading}
>
{copy.back}
</Button>
</header>
<section className="cx-prompts-form-panel" aria-labelledby={`${titleId}-panel`}>
<div className="cx-prompts-form-panel-head">
<FileText size={18} aria-hidden="true" />
<h3 id={`${titleId}-panel`}>{copy.promptDetails}</h3>
</div>
<div className="cx-prompts-form-grid">
<label className="cx-prompts-field" htmlFor={titleId}>
<span>{copy.promptName}</span>
<input
id={titleId}
type="text"
value={promptForm.title}
onChange={(event) => onPromptFormFieldChange("title", event.currentTarget.value)}
placeholder={copy.promptNamePlaceholder}
disabled={loading}
autoComplete="off"
/>
</label>
<label className="cx-prompts-field" htmlFor={filenameId}>
<span>{copy.filename}</span>
<input
id={filenameId}
type="text"
value={promptForm.filename}
onChange={(event) => onPromptFormFieldChange("filename", event.currentTarget.value)}
placeholder={copy.filenamePlaceholder}
disabled={loading}
autoComplete="off"
spellCheck={false}
/>
</label>
<label className="cx-prompts-field cx-prompts-field--content" htmlFor={contentId}>
<span>{copy.content}<small>{copy.contentHint}</small></span>
<textarea
id={contentId}
value={promptForm.content}
onChange={(event) => onPromptFormFieldChange("content", event.currentTarget.value)}
placeholder={copy.contentPlaceholder}
disabled={loading}
spellCheck={false}
/>
</label>
</div>
</section>
<div className="cx-prompts-form-actions">
<Button
size="lg"
icon={loading ? <Loader2 className="cx-prompts-spin" /> : <Save />}
onClick={() => run(onSavePrompt)}
disabled={loading}
>
{copy.save}
</Button>
</div>
</div>
);
}
export function PromptsPage({
lang,
instructionMode,
promptForm,
editingPromptId,
loading,
actionBusy,
promptSyncing,
promptCatalogReady,
promptImportRef,
promptInjectionMode,
promptModeHelpOpen,
promptModeHelpRef,
instructionEnabled,
activeInstructionTitle,
activeInjectionMode,
instructionTemplates,
activeBuiltinTemplateId,
orphanedBuiltinPrompt,
savedPrompts,
managedSavedPromptId,
preservedSavedPromptFilename,
externalPrompt,
className,
onSyncBuiltinPrompts,
onImportPrompt,
onAddPrompt,
onInstructionModeChange,
onPromptInjectionModeChange,
onTogglePromptModeHelp,
onEnableBuiltinPrompt,
onDisableInstruction,
onEnableSavedPrompt,
onDisableExternalPrompt,
onEditPrompt,
onDeletePrompt,
onPromptFormFieldChange,
onSavePrompt,
}: PromptsPageProps) {
const copy = getCopy(lang);
const helpId = useId();
const selectedModeLabel = promptInjectionMode === "append" ? copy.keepExisting : copy.replaceExisting;
const activeModeLabel = activeInjectionMode === "append" ? copy.appendMode : copy.replaceMode;
const modePending = Boolean(instructionEnabled && activeInjectionMode && activeInjectionMode !== promptInjectionMode);
const importBusy = actionBusy === "importPrompt";
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
const promptCategories = usePromptCategories(lang);
const categoryItems = useMemo<PromptCategoryItem[]>(() => [
...instructionTemplates.map((template) => ({
key: promptCategoryKey("builtin", template.id),
title: template.title,
})),
...(promptCatalogReady && orphanedBuiltinPrompt ? [{
key: promptCategoryKey("builtin", orphanedBuiltinPrompt.id),
title: orphanedBuiltinPrompt.title,
}] : []),
...savedPrompts.map((prompt) => ({
key: savedPromptCategoryKey(prompt),
title: prompt.title,
})),
...(externalPrompt ? [{
key: promptCategoryKey("external", externalPrompt.filename || externalPrompt.title),
title: externalPrompt.title || copy.existingPrompt,
}] : []),
], [copy.existingPrompt, externalPrompt, instructionTemplates, orphanedBuiltinPrompt, promptCatalogReady, savedPrompts]);
const promptIsVisible = (key: string) =>
promptCategories.categoryForPrompt(key) === promptCategories.activeCategoryId;
const visiblePromptCount = categoryItems.filter((item) => promptIsVisible(item.key)).length;
const deleteSavedPrompt = async (prompt: SavedPrompt) => {
await onDeletePrompt(prompt.id);
promptCategories.forgetPrompt(savedPromptCategoryKey(prompt));
};
if (instructionMode === "form") {
return (
<PageTransition pageKey={`prompts:${instructionMode}`}>
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
<PromptFormView
lang={lang}
promptForm={promptForm}
editingPromptId={editingPromptId}
loading={loading}
onInstructionModeChange={onInstructionModeChange}
onPromptFormFieldChange={onPromptFormFieldChange}
onSavePrompt={onSavePrompt}
/>
</section>
</PageTransition>
);
}
const handlePromptFileChange = (event: ChangeEvent<HTMLInputElement>) => {
void onImportPrompt(event.currentTarget.files?.[0]);
};
return (
<PageTransition pageKey={`prompts:${instructionMode}`}>
<section className={cx("cx-prompts-page", "cx-prompts-page--list", className)} aria-label={copy.title}>
<PromptCategoryManager
open={categoryManagerOpen}
lang={lang}
categories={promptCategories.categories}
prompts={categoryItems}
categoryForPrompt={promptCategories.categoryForPrompt}
onClose={() => setCategoryManagerOpen(false)}
onAddCategory={promptCategories.addCategory}
onRenameCategory={promptCategories.renameCategory}
onDeleteCategory={promptCategories.deleteCategory}
onMovePrompt={promptCategories.movePrompt}
/>
<header className="cx-prompts-header">
<div className="cx-prompts-heading">
<p><Sparkles size={14} aria-hidden="true" />{copy.eyebrow}</p>
<h2>{copy.title}</h2>
</div>
<div className="cx-prompts-header-actions">
<input
ref={promptImportRef}
className="cx-prompts-file-input"
type="file"
accept=".md,text/markdown,text/plain"
onChange={handlePromptFileChange}
disabled={loading}
tabIndex={-1}
aria-hidden="true"
/>
<Button
variant="secondary"
icon={promptSyncing ? <Loader2 className="cx-prompts-spin" /> : <RefreshCw />}
onClick={() => run(onSyncBuiltinPrompts)}
disabled={loading || promptSyncing}
>
{promptSyncing ? copy.syncing : copy.sync}
</Button>
<Button
variant="secondary"
icon={importBusy ? <Loader2 className="cx-prompts-spin" /> : <Upload />}
onClick={() => promptImportRef.current?.click()}
disabled={loading}
>
{importBusy ? copy.importing : copy.importMd}
</Button>
<Button className="cx-prompts-add-button" icon={<Plus />} onClick={onAddPrompt} disabled={loading}>
{copy.add}
</Button>
</div>
</header>
<section className="cx-prompts-mode-panel">
<div className="cx-prompts-active-summary">
<p>{copy.currentStatus}</p>
<div className="cx-prompts-active-title" aria-live="polite">
<span className={cx("cx-prompts-state-dot", instructionEnabled && "cx-prompts-state-dot--active")} aria-hidden="true" />
<strong>{instructionEnabled ? activeInstructionTitle : copy.noActive}</strong>
{instructionEnabled && <StatusBadge tone="success" dot={false}>{activeModeLabel}</StatusBadge>}
</div>
<span>
{instructionEnabled
? activeInjectionMode === "append" ? copy.appendDetail : copy.replaceDetail
: copy.inactiveDetail}
</span>
</div>
<div className="cx-prompts-mode-choice">
<div className="cx-prompts-mode-copy" ref={promptModeHelpRef}>
<div className="cx-prompts-mode-title">
<strong>{copy.enableMethod}</strong>
<IconButton
icon={<CircleHelp size={15} />}
label={copy.helpLabel}
variant="ghost"
size="sm"
aria-expanded={promptModeHelpOpen}
aria-controls={helpId}
onClick={onTogglePromptModeHelp}
/>
</div>
{promptModeHelpOpen && (
<div id={helpId} className="cx-prompts-mode-help" role="dialog" aria-label={copy.helpLabel}>
<div><strong>{copy.keepExisting}</strong><span>{copy.appendHelp}</span></div>
<div><strong>{copy.replaceExisting}</strong><span>{copy.replaceHelp}</span></div>
</div>
)}
<span>{modePending ? copy.pendingMode(selectedModeLabel) : copy.modeHint}</span>
</div>
<div className="cx-prompts-mode-segments" role="radiogroup" aria-label={copy.enableMethod}>
<button
type="button"
role="radio"
aria-checked={promptInjectionMode === "append"}
className={cx("cx-prompts-mode-button", promptInjectionMode === "append" && "cx-prompts-mode-button--active")}
title={copy.keepTitle}
onClick={() => onPromptInjectionModeChange("append")}
>
<CirclePlus size={16} aria-hidden="true" />
{copy.keepExisting}
</button>
<button
type="button"
role="radio"
aria-checked={promptInjectionMode === "replace"}
className={cx("cx-prompts-mode-button", promptInjectionMode === "replace" && "cx-prompts-mode-button--active")}
title={copy.replaceTitle}
onClick={() => onPromptInjectionModeChange("replace")}
>
<ArrowLeftRight size={16} aria-hidden="true" />
{copy.replaceExisting}
</button>
</div>
</div>
</section>
<div className="cx-prompt-category-toolbar">
<div className="cx-skills-tabs cx-prompt-category-tabs" role="group" aria-label={copy.manageCategories}>
{promptCategories.categories.map((category) => (
<button
type="button"
aria-pressed={category.id === promptCategories.activeCategoryId}
className={cx("cx-skills-tab", "cx-prompt-category-tab", category.id === promptCategories.activeCategoryId && "cx-skills-tab--active")}
key={category.id}
onClick={() => promptCategories.setActiveCategoryId(category.id)}
>
{category.name}
</button>
))}
</div>
<button type="button" className="cx-prompt-category-manage" onClick={() => setCategoryManagerOpen(true)}>
<Settings2 size={16} aria-hidden="true" />{copy.manageCategories}
</button>
</div>
<PageTransition pageKey={`prompts-category:${promptCategories.activeCategoryId}`}>
<section className="cx-prompts-list-panel" aria-label={lang === "zh" ? "提示词模板" : "Prompt templates"}>
<div className="cx-prompts-list">
{visiblePromptCount === 0 && (
<div className="cx-prompt-category-list-empty"><FileText size={22} aria-hidden="true" />{copy.emptyCategory}</div>
)}
{instructionTemplates.filter((template) =>
promptIsVisible(promptCategoryKey("builtin", template.id))).map((template) => {
const enabled = template.id === activeBuiltinTemplateId;
return (
<PromptRow
key={template.id}
title={template.title}
description={template.subtitle}
enabled={enabled}
loading={loading}
toggleLabel={enabled ? copy.disable : copy.enable}
onToggle={() => enabled ? onDisableInstruction() : onEnableBuiltinPrompt(template.id)}
>
{enabled && (
<div className="cx-prompts-row-meta">
<StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>
</div>
)}
</PromptRow>
);
})}
{promptCatalogReady && orphanedBuiltinPrompt
&& promptIsVisible(promptCategoryKey("builtin", orphanedBuiltinPrompt.id)) && (
<PromptRow
title={orphanedBuiltinPrompt.title}
description={orphanedBuiltinPrompt.description || copy.removedDescription}
enabled
loading={loading}
toggleLabel={copy.disable}
onToggle={onDisableInstruction}
>
<div className="cx-prompts-row-meta">
<StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>
<StatusBadge tone="warning" dot={false}>{copy.onlineRemoved}</StatusBadge>
</div>
</PromptRow>
)}
{savedPrompts.filter((prompt) =>
promptIsVisible(savedPromptCategoryKey(prompt))).map((prompt) => {
const managed = prompt.id === managedSavedPromptId;
const preserved = !managed && Boolean(preservedSavedPromptFilename) && prompt.filename === preservedSavedPromptFilename;
const enabled = managed || preserved;
return (
<PromptRow
key={prompt.id}
title={prompt.title}
description={preserved ? copy.preservedDescription : copy.customDescription}
enabled={enabled}
loading={loading}
toggleLabel={managed ? copy.disable : preserved ? copy.disableExternal : copy.enable}
onToggle={() => managed
? onDisableInstruction()
: preserved
? onDisableExternalPrompt()
: onEnableSavedPrompt(prompt.id)}
actions={(
<div className="cx-prompts-icon-actions">
<IconButton
icon={<PencilLine size={15} />}
label={copy.edit}
size="sm"
onClick={() => onEditPrompt(prompt)}
disabled={loading}
/>
<IconButton
icon={<Trash2 size={15} />}
label={copy.remove}
variant="danger"
size="sm"
onClick={() => void deleteSavedPrompt(prompt)}
disabled={loading}
/>
</div>
)}
>
{managed && (
<div className="cx-prompts-row-meta">
<StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>
</div>
)}
{!managed && preserved && (
<div className="cx-prompts-row-meta">
<StatusBadge tone="info" dot={false}>{copy.current} · {copy.appendMode}</StatusBadge>
</div>
)}
</PromptRow>
);
})}
{externalPrompt
&& promptIsVisible(promptCategoryKey("external", externalPrompt.filename || externalPrompt.title)) && (
<PromptRow
title={externalPrompt.title || copy.existingPrompt}
description={externalPrompt.description}
enabled
loading={loading}
toggleLabel={copy.disableExternal}
onToggle={onDisableExternalPrompt}
>
{externalPrompt.filename && <code className="cx-prompts-external-path">{externalPrompt.filename}</code>}
</PromptRow>
)}
</div>
</section>
</PageTransition>
</section>
</PageTransition>
);
}
+556
View File
@@ -0,0 +1,556 @@
import { useId, useState } from "react";
import type { ReactNode } from "react";
import {
Activity,
AlertTriangle,
ArrowLeft,
CheckCircle2,
Eye,
EyeOff,
Loader2,
PencilLine,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Ref } from "react";
import { PageTransition } from "../components/PageTransition";
import { Button, Checkbox, ModalShell } from "../components/ui";
import type { ProviderMode } from "../types";
import "../styles/providers-page.css";
export type ProviderRowSource = "official" | "local" | "detected";
export type ProviderRow = {
id: string;
source: ProviderRowSource;
providerName: string;
baseUrl: string;
model: string;
apiKey?: string;
wireApi: string;
requiresOpenaiAuth: boolean;
isCurrent: boolean;
sourceLabel?: string;
editable?: boolean;
deletable?: boolean;
testable?: boolean;
testingKey?: string;
meta?: ReactNode;
};
export type ProviderFormValue = {
apiKey: string;
baseUrl: string;
providerName: string;
model: string;
wireApi: string;
requiresOpenaiAuth: boolean;
};
export type OfficialFormValue = {
model: string;
authJson: string;
};
export type ProviderCopy = {
eyebrow: string;
title: string;
subtitle: string;
importLabel: string;
addLabel: string;
noProviders: string;
currentLabel: string;
enableLabel: string;
testLabel: string;
editLabel: string;
removeLabel: string;
deleteTitle: string;
deleteDescription: (providerName: string) => string;
deleteCurrentDescription: (providerName: string) => string;
deleteCancelLabel: string;
deleteConfirmLabel: string;
noBaseUrlLabel: string;
officialEyebrow: string;
officialTitle: string;
officialHint: string;
officialUrlLabel: string;
authPathLabel: string;
officialCurrentLabel: string;
officialAuthLabel: string;
officialSaveLabel: string;
cancelLabel: string;
formEyebrow: string;
formAddTitle: string;
formEditTitle: string;
formHint: string;
apiConfigTitle: string;
apiConfigDescription: string;
apiKeyLabel: string;
apiKeyPlaceholder: string;
showApiKeyLabel: string;
hideApiKeyLabel: string;
baseUrlLabel: string;
nameLabel: string;
modelLabel: string;
fetchModelsLabel: string;
fetchingModelsLabel: string;
chooseModelLabel: (count: number) => string;
wireApiLabel: string;
requiresAuthLabel: string;
authPreviewTitle: string;
authPreviewDescription: string;
tomlTitle: string;
tomlDescription: string;
resetTomlLabel: string;
saveLabel: string;
savingLabel: string;
};
export type ProviderOfficialInfo = {
officialUrl: ReactNode;
authPath: ReactNode;
current: ReactNode;
};
export type ProvidersPageProps = {
lang: "zh" | "en";
copy: ProviderCopy;
mode: ProviderMode;
providerRows: readonly ProviderRow[];
loading: boolean;
testingId: string;
actionBusy?: string;
editingProviderId: string | null;
providerForm: ProviderFormValue;
officialForm: OfficialFormValue;
officialInfo: ProviderOfficialInfo;
providerAuthPreview: ReactNode;
providerTomlDraft: string;
providerTomlRef?: Ref<HTMLTextAreaElement>;
apiKeyVisible: boolean;
availableModels: readonly string[];
fetchingModels: boolean;
onImportCcSwitch: () => void;
onAddProvider: () => void;
onEnableProvider: (row: ProviderRow) => void;
onTestProvider: (row: ProviderRow) => void;
onEditProvider: (row: ProviderRow) => void;
onDeleteProvider: (row: ProviderRow) => Promise<boolean>;
onCancelMode: () => void;
onOfficialModelChange: (value: string) => void;
onOfficialAuthChange: (value: string) => void;
onSaveOfficial: () => void;
onApiKeyChange: (value: string) => void;
onBaseUrlChange: (value: string) => void;
onProviderNameChange: (value: string) => void;
onProviderModelChange: (value: string) => void;
onFetchModels: () => void;
onWireApiChange: (value: string) => void;
onRequiresAuthChange: (value: boolean) => void;
onToggleApiKeyVisibility: () => void;
onProviderTomlDraftChange: (value: string) => void;
onResetProviderToml: () => void;
onSaveProvider: () => void;
};
type FieldProps = {
label: string;
children: ReactNode;
className?: string;
};
function Field({ label, children, className }: FieldProps) {
return (
<label className={`cx-providers-field${className ? ` ${className}` : ""}`}>
<span>{label}</span>
{children}
</label>
);
}
function ProviderAvatar({ row }: { row: ProviderRow }) {
const initial = row.providerName.trim().slice(0, 1).toUpperCase() || "?";
const className = [
"cx-providers-avatar",
row.source === "official" ? "cx-providers-avatar--official" : "",
row.isCurrent ? "cx-providers-avatar--current" : "",
].filter(Boolean).join(" ");
return (
<div className={className} aria-hidden="true">
{row.source === "official" ? <span className="cx-providers-openai-logo" /> : initial}
</div>
);
}
function ActionIconButton({
icon: Icon,
label,
onClick,
disabled,
danger = false,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
}) {
return (
<button
type="button"
className={`cx-providers-icon-button${danger ? " cx-providers-icon-button--danger" : ""}`}
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
>
<Icon size={15} strokeWidth={1.9} aria-hidden="true" />
</button>
);
}
function ListPage({
copy,
providerRows,
loading,
testingId,
actionBusy,
onImportCcSwitch,
onAddProvider,
onEnableProvider,
onTestProvider,
onEditProvider,
onDeleteProvider,
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
const [deleting, setDeleting] = useState(false);
const closeDeleteDialog = () => {
if (!deleting) setProviderToDelete(null);
};
const confirmDelete = async () => {
if (!providerToDelete || deleting) return;
setDeleting(true);
try {
if (await onDeleteProvider(providerToDelete)) setProviderToDelete(null);
} finally {
setDeleting(false);
}
};
return (
<>
<header className="cx-providers-header">
<div className="cx-providers-header-copy">
<div className="cx-providers-eyebrow">{copy.eyebrow}</div>
<h2>{copy.title}</h2>
<p>{copy.subtitle}</p>
</div>
<div className="cx-providers-header-actions">
<button
type="button"
className="cx-providers-button cx-providers-button--secondary"
onClick={onImportCcSwitch}
disabled={loading || Boolean(actionBusy)}
>
{actionBusy === "importCcSwitch" ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
{copy.importLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--dark" onClick={onAddProvider} disabled={loading}>
<Plus size={15} aria-hidden="true" />
{copy.addLabel}
</button>
</div>
</header>
<div className="cx-providers-list" role="list">
{providerRows.length === 0 ? (
<div className="cx-providers-empty" role="status">{copy.noProviders}</div>
) : providerRows.map((row) => {
const testingKey = row.testingKey || `${row.source}-${row.id}`;
const isTesting = testingId === testingKey;
return (
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
<ProviderAvatar row={row} />
<div className="cx-providers-row-main">
<div className="cx-providers-row-title">
<strong>{row.providerName}</strong>
{row.sourceLabel && (
<span className={`cx-providers-source-badge${row.source === "official" ? " cx-providers-source-badge--official" : ""}`}>
{row.sourceLabel}
</span>
)}
</div>
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
{row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
</div>
<div className="cx-providers-row-actions">
{row.isCurrent && <span className="cx-providers-current-badge"><span aria-hidden="true" />{copy.currentLabel}</span>}
<button
type="button"
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
onClick={() => onEnableProvider(row)}
disabled={loading || row.isCurrent}
>
{copy.enableLabel}
</button>
{row.testable !== false && (
<ActionIconButton
icon={isTesting ? Loader2 : Activity}
label={copy.testLabel}
onClick={() => onTestProvider(row)}
disabled={loading || isTesting}
/>
)}
{row.editable !== false && (
<ActionIconButton icon={PencilLine} label={copy.editLabel} onClick={() => onEditProvider(row)} disabled={loading} />
)}
{row.deletable && (
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={loading} danger />
)}
</div>
</article>
);
})}
</div>
<ModalShell
open={Boolean(providerToDelete)}
onClose={closeDeleteDialog}
title={copy.deleteTitle}
description={providerToDelete
? providerToDelete.isCurrent
? copy.deleteCurrentDescription(providerToDelete.providerName)
: copy.deleteDescription(providerToDelete.providerName)
: undefined}
size="sm"
closeLabel={copy.deleteCancelLabel}
closeOnBackdrop={!deleting}
closeOnEscape={!deleting}
showCloseButton={!deleting}
className="cx-provider-delete-dialog"
bodyClassName="cx-provider-delete-dialog-body"
footer={(
<>
<Button variant="secondary" onClick={closeDeleteDialog} disabled={deleting} data-initial-focus>
{copy.deleteCancelLabel}
</Button>
<Button variant="danger" icon={deleting ? <Loader2 size={16} className="cx-providers-spin" /> : <Trash2 size={16} />} onClick={() => void confirmDelete()} disabled={deleting}>
{copy.deleteConfirmLabel}
</Button>
</>
)}
>
<div className="cx-provider-delete-warning">
<span aria-hidden="true"><AlertTriangle size={22} /></span>
<strong>{providerToDelete?.providerName}</strong>
</div>
</ModalShell>
</>
);
}
function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { eyebrow: string; title: string; description: string; cancelLabel: string; onCancel: () => void }) {
return (
<header className="cx-providers-form-header">
<div>
<div className="cx-providers-eyebrow">{eyebrow}</div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onCancel}>
<ArrowLeft size={15} aria-hidden="true" />
{cancelLabel}
</button>
</header>
);
}
function OfficialForm({
copy,
officialForm,
officialInfo,
loading,
onCancelMode,
onOfficialModelChange,
onOfficialAuthChange,
onSaveOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial">) {
return (
<>
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
<div className="cx-providers-info-grid">
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
<div><span>{copy.officialCurrentLabel}</span><code>{officialInfo.current}</code></div>
</div>
<div className="cx-providers-form-grid cx-providers-form-grid--single">
<Field label={copy.modelLabel}><input value={officialForm.model} onChange={(event) => onOfficialModelChange(event.target.value)} /></Field>
</div>
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
<textarea
className="cx-providers-code-editor cx-providers-auth-editor"
value={officialForm.authJson}
onChange={(event) => onOfficialAuthChange(event.target.value)}
wrap="off"
spellCheck={false}
/>
</Field>
<div className="cx-providers-form-actions cx-providers-form-actions--save">
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={loading}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
</div>
</>
);
}
function ProviderForm({
copy,
providerForm,
loading,
editingProviderId,
providerAuthPreview,
providerTomlDraft,
providerTomlRef,
apiKeyVisible,
availableModels,
fetchingModels,
onCancelMode,
onApiKeyChange,
onBaseUrlChange,
onProviderNameChange,
onProviderModelChange,
onFetchModels,
onWireApiChange,
onRequiresAuthChange,
onToggleApiKeyVisibility,
onProviderTomlDraftChange,
onResetProviderToml,
onSaveProvider,
}: Pick<ProvidersPageProps, "copy" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
const modelListId = useId();
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
return (
<>
<ModeHeader
eyebrow={copy.formEyebrow}
title={editingProviderId ? copy.formEditTitle : copy.formAddTitle}
description={copy.formHint}
cancelLabel={copy.cancelLabel}
onCancel={onCancelMode}
/>
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading">
<div><h3>{copy.apiConfigTitle}</h3><p>{copy.apiConfigDescription}</p></div>
</div>
<div className="cx-providers-form-grid cx-providers-form-grid--provider">
<Field label={copy.apiKeyLabel} className="cx-providers-field--full">
<div className="cx-providers-secret-input">
<input
type={apiKeyVisible ? "text" : "password"}
value={providerForm.apiKey}
onChange={(event) => onApiKeyChange(event.target.value)}
placeholder={copy.apiKeyPlaceholder}
/>
<button type="button" onClick={onToggleApiKeyVisibility} title={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel} aria-label={apiKeyVisible ? copy.hideApiKeyLabel : copy.showApiKeyLabel}>
{apiKeyVisible ? <EyeOff size={15} aria-hidden="true" /> : <Eye size={15} aria-hidden="true" />}
</button>
</div>
</Field>
<Field label={copy.baseUrlLabel} className="cx-providers-field--full"><input value={providerForm.baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} /></Field>
<Field label={copy.nameLabel}><input value={providerForm.providerName} onChange={(event) => onProviderNameChange(event.target.value)} /></Field>
<Field label={copy.modelLabel}>
<div className="cx-providers-model-input-row">
<input
value={providerForm.model}
list={availableModels.length ? modelListId : undefined}
aria-label={copy.modelLabel}
onChange={(event) => onProviderModelChange(event.target.value)}
/>
<button
type="button"
className="cx-providers-button cx-providers-button--secondary cx-providers-button--small cx-providers-fetch-models"
onClick={onFetchModels}
disabled={loading || fetchingModels || !canFetchModels}
title={copy.fetchModelsLabel}
aria-label={copy.fetchModelsLabel}
>
{fetchingModels
? <Loader2 size={14} className="cx-providers-spin" aria-hidden="true" />
: <RefreshCw size={14} aria-hidden="true" />}
{fetchingModels ? copy.fetchingModelsLabel : copy.fetchModelsLabel}
</button>
</div>
{availableModels.length > 0 && (
<>
<datalist id={modelListId}>
{availableModels.map((model) => <option value={model} key={model} />)}
</datalist>
<select
className="cx-providers-model-select"
value=""
aria-label={copy.chooseModelLabel(availableModels.length)}
onChange={(event) => {
if (event.target.value) onProviderModelChange(event.target.value);
}}
>
<option value="">{copy.chooseModelLabel(availableModels.length)}</option>
{availableModels.map((model) => <option value={model} key={model}>{model}</option>)}
</select>
</>
)}
</Field>
<Field label={copy.wireApiLabel}>
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)}>
<option value="responses">responses</option>
<option value="chat">chat</option>
</select>
</Field>
<Checkbox
className="cx-providers-checkbox cx-providers-checkbox--full"
checked={providerForm.requiresOpenaiAuth}
onCheckedChange={onRequiresAuthChange}
label={copy.requiresAuthLabel}
/>
</div>
</section>
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading"><div><h3>{copy.authPreviewTitle}</h3><p>{copy.authPreviewDescription}</p></div></div>
<div className="cx-providers-preview">{providerAuthPreview}</div>
</section>
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
</div>
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} spellCheck={false} />
</section>
<div className="cx-providers-form-actions cx-providers-form-actions--save">
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={loading}>
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
{loading ? copy.savingLabel : copy.saveLabel}
</button>
</div>
</>
);
}
export function ProvidersPage(props: ProvidersPageProps) {
return (
<PageTransition pageKey={`providers:${props.mode}`}>
<section className={`cx-providers cx-page cx-providers--${props.mode}`}>
{props.mode === "list" && <ListPage {...props} />}
{props.mode === "official" && <OfficialForm {...props} />}
{props.mode === "form" && <ProviderForm {...props} />}
</section>
</PageTransition>
);
}
@@ -0,0 +1,493 @@
import {
AlertCircle,
CheckCircle2,
FolderTree,
History,
Info,
Loader2,
RefreshCw,
Search,
Trash2,
Zap,
} from "lucide-react";
import { Button, Checkbox, ModalShell, cx } from "../components/ui";
import "../styles/session-management.css";
export type Lang = "zh" | "en";
export type SessionPreview = {
id: string;
title: string;
modelProvider?: string | null;
model?: string | null;
cwd?: string | null;
rolloutPath?: string | null;
updatedAtMs?: number | null;
archived: boolean;
hasUserEvent: boolean;
isSubagent: boolean;
needsSync: boolean;
};
export type SessionSyncStatus = {
codexDir: string;
targetProvider: string;
rolloutFiles: number;
sessionMetaCount: number;
mismatchedRollouts: number;
mismatchedSessionMeta: number;
sqliteDbs: number;
sqliteThreads: number;
topLevelThreads: number;
subagentThreads: number;
mismatchedThreads: number;
needsSync: boolean;
backupDir?: string | null;
warnings: string[];
sessions: SessionPreview[];
};
type SessionManagementPageProps = {
active: boolean;
lang: Lang;
sessionStatus: SessionSyncStatus | null;
sessionHasMismatches: boolean;
sessionSyncCount: number;
sessionTargetLabel: string;
sessionVisibleTotal: number;
sessionPreviewTruncated: boolean;
visibleSessions: SessionPreview[];
filteredSessions: SessionPreview[];
allSessionsByCwd: Map<string, SessionPreview[]>;
groupedSessions: Array<[string, SessionPreview[]]>;
selectedSessionIds: string[];
selectedSessionSet: Set<string>;
selectedSessions: SessionPreview[];
sessionQuery: string;
sessionGroupByCwd: boolean;
showInternalSessions: boolean;
loading: boolean;
actionBusy: string;
sessionDeleteConfirmOpen: boolean;
sessionDeleteBusy: boolean;
sessionDeleteSafetyConfirmed: boolean;
onCheckSessions: () => void;
onSyncSessions: () => void;
onSessionQueryChange: (value: string) => void;
onSessionGroupByCwdChange: (checked: boolean) => void;
onShowInternalSessionsChange: (checked: boolean) => void;
onOpenDeleteConfirm: () => void;
onToggleSessionSelected: (id: string) => void;
onSetSessionGroupSelected: (sessions: SessionPreview[], checked: boolean) => void;
onCloseDeleteConfirm: () => void;
onDeleteSelectedSessions: () => void;
onDeleteSafetyConfirmedChange: (checked: boolean) => void;
};
function formatSessionTime(value?: number | null, lang: Lang = "zh") {
if (!value) return lang === "zh" ? "未知时间" : "Unknown time";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return lang === "zh" ? "未知时间" : "Unknown time";
return date.toLocaleString(lang === "zh" ? "zh-CN" : undefined, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function compactPath(value: string | null | undefined, max = 58, missing = "未记录路径") {
if (!value) return missing;
const normalized = value.replace(/\\/g, "/");
if (normalized.length <= max) return normalized;
const parts = normalized.split("/").filter(Boolean);
if (parts.length >= 3) {
const tail = parts.slice(-3).join("/");
return `…/${tail}`;
}
return `…${normalized.slice(-max + 1)}`;
}
function shortId(value: string) {
return value.length > 13 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
}
export function SessionManagementPage({
active,
lang,
sessionStatus,
sessionHasMismatches,
sessionSyncCount,
sessionTargetLabel,
sessionVisibleTotal,
sessionPreviewTruncated,
visibleSessions,
filteredSessions,
allSessionsByCwd,
groupedSessions,
selectedSessionIds,
selectedSessionSet,
selectedSessions,
sessionQuery,
sessionGroupByCwd,
showInternalSessions,
loading,
actionBusy,
sessionDeleteConfirmOpen,
sessionDeleteBusy,
sessionDeleteSafetyConfirmed,
onCheckSessions,
onSyncSessions,
onSessionQueryChange,
onSessionGroupByCwdChange,
onShowInternalSessionsChange,
onOpenDeleteConfirm,
onToggleSessionSelected,
onSetSessionGroupSelected,
onCloseDeleteConfirm,
onDeleteSelectedSessions,
onDeleteSafetyConfirmedChange,
}: SessionManagementPageProps) {
const isChinese = lang === "zh";
const copy = isChinese
? {
syncEyebrow: "会话同步",
title: "会话管理",
description: "检查本地会话是否跟当前供应商一致,需要时一键同步。不会修改聊天内容。",
syncTo: "同步到",
check: "检查会话",
checking: "检查中...",
sync: "同步会话",
syncing: "同步中...",
clickToCheck: "点击检查会话",
needsSync: (count: number) => `有 ${count} 条会话需要同步`,
allSynced: "全部会话已同步",
sessionCount: (count: number) => `${count} 条会话`,
local: "本地会话",
list: "会话列表",
shown: (shown: number, total: number) => `展示 ${shown} / ${total} 条`,
loaded: (count: number) => `当前加载 ${count} 条`,
search: "搜索标题 / 项目 / 供应商 / ID",
groupByProject: "按项目路径分组",
showInternal: (count: number) => `显示内部会话 (${count})`,
deleteSelected: "删除选中",
deleteMany: (count: number) => `永久删除 ${count} 条`,
selectAll: "选择当前列表中的全部会话",
selectProject: (path: string, count: number) => `选择项目 ${path} 的 ${count} 条会话`,
projectCount: (count: number, truncated: boolean) => `${truncated ? "已加载 " : ""}${count} 条`,
projectShown: (shown: number, total: number) => `显示 ${shown} / 共 ${total} 条`,
selectSession: "选择会话",
archived: "已归档",
internal: "内部",
pending: "待同步",
unknownProvider: "未知供应商",
noModel: "未记录",
noMatch: "没有匹配的会话。",
noSessions: "还没有读取到会话。点击右上角“检查会话”刷新。",
diagnostics: "诊断信息",
diagnosticsCount: (count: number) => `${count} 条 · 点击查看`,
deleteTitle: (count: number) => `永久删除 ${count} 条会话`,
irreversible: "此操作不可恢复",
deleteDescription: "所选会话将从 Codex 的本地数据中永久删除,不会移入回收站,也不会创建新的备份。",
deleteChildren: "由这些会话派生的子会话也会一并删除。",
closeClients: "请先关闭正在使用这些会话的 Codex 窗口或 CLI。",
pendingDelete: "待删除会话",
moreSessions: (count: number) => `另有 ${count} 条会话未在此处展开`,
safetyCheck: "我已关闭其他正在使用这些会话的 Codex 窗口或 CLI",
cancel: "取消",
deleting: "正在永久删除...",
confirmDelete: (count: number) => `确认永久删除 ${count} 条`,
}
: {
syncEyebrow: "SESSION SYNC",
title: "Session management",
description: "Check whether local sessions match the current provider and sync them when needed. Chat content is not changed.",
syncTo: "Sync to",
check: "Check sessions",
checking: "Checking...",
sync: "Sync sessions",
syncing: "Syncing...",
clickToCheck: "Check sessions to get started",
needsSync: (count: number) => `${count} session(s) need syncing`,
allSynced: "All sessions are synced",
sessionCount: (count: number) => `${count} sessions`,
local: "LOCAL SESSIONS",
list: "Sessions",
shown: (shown: number, total: number) => `${shown} / ${total} shown`,
loaded: (count: number) => `${count} loaded`,
search: "Search title / project / provider / ID",
groupByProject: "Group by project path",
showInternal: (count: number) => `Show internal sessions (${count})`,
deleteSelected: "Delete selected",
deleteMany: (count: number) => `Delete ${count} permanently`,
selectAll: "Select all sessions in the current list",
selectProject: (path: string, count: number) => `Select ${count} sessions in ${path}`,
projectCount: (count: number, truncated: boolean) => `${count}${truncated ? " loaded" : ""}`,
projectShown: (shown: number, total: number) => `${shown} / ${total} shown`,
selectSession: "Select session",
archived: "Archived",
internal: "Internal",
pending: "Needs sync",
unknownProvider: "Unknown provider",
noModel: "Not recorded",
noMatch: "No matching sessions.",
noSessions: "No sessions loaded. Click Check sessions to refresh.",
diagnostics: "Diagnostics",
diagnosticsCount: (count: number) => `${count} · click to view`,
deleteTitle: (count: number) => `Permanently delete ${count} session(s)`,
irreversible: "This cannot be undone",
deleteDescription: "Selected sessions will be permanently deleted from Codex local data. There is no recycle bin or new backup.",
deleteChildren: "Child sessions spawned from these sessions will also be deleted.",
closeClients: "Close other Codex windows or CLIs using these sessions first.",
pendingDelete: "Sessions to delete",
moreSessions: (count: number) => `${count} more session(s) not shown`,
safetyCheck: "I closed other Codex windows or CLIs using these sessions",
cancel: "Cancel",
deleting: "Deleting permanently...",
confirmDelete: (count: number) => `Delete ${count} permanently`,
};
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
const visibleSelectionIsPartial = selectedVisibleCount > 0 && !allVisibleSelected;
return (
<>
<ModalShell
open={dialogOpen}
onClose={onCloseDeleteConfirm}
title={copy.deleteTitle(selectedSessions.length)}
description={copy.deleteDescription}
size="lg"
closeLabel={isChinese ? "关闭" : "Close"}
closeOnBackdrop={!sessionDeleteBusy}
closeOnEscape={!sessionDeleteBusy}
showCloseButton={!sessionDeleteBusy}
className="cx-session-delete-dialog"
bodyClassName="cx-session-delete-modal-body"
footer={(
<>
<Button variant="secondary" onClick={onCloseDeleteConfirm} disabled={sessionDeleteBusy} data-initial-focus>
{copy.cancel}
</Button>
<Button
variant="danger"
className="cx-session-delete-confirm"
icon={sessionDeleteBusy ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
onClick={onDeleteSelectedSessions}
disabled={sessionDeleteBusy || !sessionDeleteSafetyConfirmed}
>
{sessionDeleteBusy ? copy.deleting : copy.confirmDelete(selectedSessions.length)}
</Button>
</>
)}
>
<div className="cx-session-delete-warning">
<AlertCircle size={19} strokeWidth={1.9} aria-hidden="true" />
<div>
<strong>{copy.irreversible}</strong>
<p>{copy.deleteChildren}</p>
<p>{copy.closeClients}</p>
</div>
</div>
<div className="cx-session-delete-list" aria-label={copy.pendingDelete}>
{selectedSessions.slice(0, 8).map((item) => (
<div className="cx-session-delete-item" key={item.id}>
<strong title={item.title}>{item.title || (isChinese ? "未命名会话" : "Untitled session")}</strong>
<code>#{shortId(item.id)}</code>
<span title={item.cwd || item.rolloutPath || undefined}>
{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}
</span>
</div>
))}
{selectedSessions.length > 8 && <p className="cx-session-delete-more">{copy.moreSessions(selectedSessions.length - 8)}</p>}
</div>
<Checkbox
className="cx-session-safety-check"
checked={sessionDeleteSafetyConfirmed}
onCheckedChange={onDeleteSafetyConfirmedChange}
disabled={sessionDeleteBusy}
label={copy.safetyCheck}
/>
</ModalShell>
<section className={cx("cx-session-page", !active && "page-pane-hidden")}>
<header className="cx-session-header">
<div className="cx-session-heading">
<p className="cx-session-eyebrow"><RefreshCw size={13} strokeWidth={2} aria-hidden="true" />{copy.syncEyebrow}</p>
<h2>{copy.title}</h2>
<p className="cx-session-description">{copy.description}</p>
</div>
<div className="cx-session-header-actions">
<span className="cx-session-target"><span>{copy.syncTo}</span><strong>{sessionTargetLabel}</strong></span>
<button type="button" className="cx-session-button cx-session-button--secondary" onClick={onCheckSessions} disabled={loading} aria-busy={actionBusy === "checkSessions"}>
{actionBusy === "checkSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <RefreshCw size={16} aria-hidden="true" />}
{actionBusy === "checkSessions" ? copy.checking : copy.check}
</button>
<button type="button" className="cx-session-button cx-session-button--primary" onClick={onSyncSessions} disabled={loading || !sessionHasMismatches} aria-busy={actionBusy === "syncSessions"}>
{actionBusy === "syncSessions" ? <Loader2 size={16} className="cx-session-spin" aria-hidden="true" /> : <Zap size={16} aria-hidden="true" />}
{actionBusy === "syncSessions" ? copy.syncing : copy.sync}
</button>
</div>
</header>
<div className={cx("cx-session-summary", sessionHasMismatches ? "cx-session-summary--needs-sync" : "cx-session-summary--synced")}>
<span className="cx-session-summary-status">
{!sessionStatus ? <Info size={15} aria-hidden="true" /> : sessionHasMismatches ? <AlertCircle size={15} aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
{!sessionStatus ? copy.clickToCheck : sessionHasMismatches ? copy.needsSync(sessionSyncCount) : copy.allSynced}
</span>
<span className="cx-session-summary-count">{copy.sessionCount(sessionStatus?.topLevelThreads ?? 0)}</span>
</div>
<div className="cx-session-list-card">
<div className="cx-session-list-heading">
<div>
<p className="cx-session-section-label">{copy.local}</p>
<h3>{copy.list}</h3>
</div>
<span
className="cx-session-total"
title={sessionPreviewTruncated ? copy.loaded(visibleSessions.length) : undefined}
>
{copy.shown(filteredSessions.length, sessionVisibleTotal)}
</span>
</div>
<div className="cx-session-toolbar">
<label className="cx-session-search">
<Search size={16} strokeWidth={1.9} aria-hidden="true" />
<input
value={sessionQuery}
onChange={(event) => onSessionQueryChange(event.target.value)}
placeholder={copy.search}
aria-label={copy.search}
/>
</label>
<Checkbox
className={cx("cx-session-toggle", sessionGroupByCwd && "cx-session-toggle--active")}
checked={sessionGroupByCwd}
onCheckedChange={onSessionGroupByCwdChange}
label={<><FolderTree size={15} strokeWidth={1.9} aria-hidden="true" /><span>{copy.groupByProject}</span></>}
/>
{(sessionStatus?.subagentThreads ?? 0) > 0 && (
<Checkbox
className={cx("cx-session-toggle", showInternalSessions && "cx-session-toggle--active")}
checked={showInternalSessions}
onCheckedChange={onShowInternalSessionsChange}
label={copy.showInternal(sessionStatus?.subagentThreads ?? 0)}
/>
)}
<button
type="button"
className={cx("cx-session-button cx-session-delete-trigger", selectedSessionIds.length > 0 ? "cx-session-button--danger" : "cx-session-button--secondary")}
onClick={onOpenDeleteConfirm}
disabled={loading || sessionDeleteBusy || selectedSessionIds.length === 0}
title={selectedSessionIds.length > 0 ? undefined : copy.deleteSelected}
>
<Trash2 size={15} strokeWidth={1.9} aria-hidden="true" />
{selectedSessionIds.length > 0 ? copy.deleteMany(selectedSessionIds.length) : copy.deleteSelected}
</button>
</div>
{filteredSessions.length > 0 ? (
<div className="cx-session-scroll" role="table" aria-label={copy.list}>
<div className="cx-session-column-head" role="row">
<Checkbox
className="cx-session-select-all"
checked={allVisibleSelected}
indeterminate={visibleSelectionIsPartial}
onCheckedChange={(checked) => onSetSessionGroupSelected(filteredSessions, checked)}
aria-label={copy.selectAll}
disabled={loading || sessionDeleteBusy}
/>
<span>{isChinese ? "会话" : "Session"}</span>
<span>{isChinese ? "更新时间" : "Updated"}</span>
<span>{isChinese ? "供应商" : "Provider"}</span>
<span>{isChinese ? "模型" : "Model"}</span>
<span>ID</span>
</div>
<div className="cx-session-table-body">
{groupedSessions.map(([group, items]) => {
const projectSessions = allSessionsByCwd.get(group) || items;
const selectedProjectCount = projectSessions.filter((item) => selectedSessionSet.has(item.id)).length;
const projectSelected = projectSessions.length > 0 && selectedProjectCount === projectSessions.length;
const projectPartiallySelected = selectedProjectCount > 0 && !projectSelected;
const groupCountLabel = items.length === projectSessions.length
? copy.projectCount(projectSessions.length, sessionPreviewTruncated)
: copy.projectShown(items.length, projectSessions.length);
return (
<div className="cx-session-group" key={group}>
{sessionGroupByCwd && (
<label className={cx("cx-session-group-heading", projectSelected && "cx-session-group-heading--selected", projectPartiallySelected && "cx-session-group-heading--partial")}>
<input
className="cx-session-checkbox"
type="checkbox"
ref={(input) => {
if (input) input.indeterminate = projectPartiallySelected;
}}
checked={projectSelected}
onChange={(event) => onSetSessionGroupSelected(projectSessions, event.target.checked)}
aria-label={copy.selectProject(group, projectSessions.length)}
/>
<span title={group}>{compactPath(group, 96, isChinese ? "未记录路径" : "No path recorded")}</span>
<em>{groupCountLabel}</em>
</label>
)}
{items.map((item) => (
<label className={cx("cx-session-row", item.needsSync && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")} key={item.id}>
<span className="cx-session-select-box" title={copy.selectSession}>
<input
className="cx-session-checkbox"
type="checkbox"
checked={selectedSessionSet.has(item.id)}
onChange={() => onToggleSessionSelected(item.id)}
aria-label={`${copy.selectSession}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")} (#${shortId(item.id)})`}
/>
</span>
<div className="cx-session-row-copy">
<div className="cx-session-row-title">
<strong title={item.title}>{item.title || (isChinese ? "未命名会话" : "Untitled session")}</strong>
{item.archived && <span className="cx-session-state">{copy.archived}</span>}
{item.isSubagent && <span className="cx-session-state">{copy.internal}</span>}
{item.needsSync && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
</div>
{!sessionGroupByCwd && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
</div>
<span className="cx-session-meta cx-session-meta--time" title={item.updatedAtMs ? new Date(item.updatedAtMs).toLocaleString() : undefined}>{formatSessionTime(item.updatedAtMs, lang)}</span>
<code className="cx-session-meta cx-session-meta--provider" title={item.modelProvider || undefined}>{item.modelProvider || copy.unknownProvider}</code>
<span className="cx-session-meta cx-session-meta--model" title={item.model || undefined}>{item.model || copy.noModel}</span>
<small className="cx-session-meta cx-session-meta--id" title={item.id}>#{shortId(item.id)}</small>
</label>
))}
</div>
);
})}
</div>
</div>
) : (
<div className="cx-session-empty">
<History size={22} strokeWidth={1.7} aria-hidden="true" />
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
</div>
)}
</div>
{sessionStatus?.warnings?.length ? (
<details className="cx-session-diagnostics">
<summary>
<AlertCircle size={15} strokeWidth={1.9} aria-hidden="true" />
<span>{copy.diagnostics}</span>
<small>{copy.diagnosticsCount(sessionStatus.warnings.length)}</small>
</summary>
<div className="cx-session-diagnostic-items">
{sessionStatus.warnings.map((item, index) => <p key={`${index}-${item}`}><Info size={14} aria-hidden="true" />{item}</p>)}
</div>
</details>
) : null}
</section>
</>
);
}
+458
View File
@@ -0,0 +1,458 @@
import { useId } from "react";
import type { ChangeEvent, RefObject } from "react";
import {
AlertCircle,
Blocks,
Download,
FolderInput,
Loader2,
PackageOpen,
PlugZap,
RefreshCw,
Sparkles,
Upload,
} from "lucide-react";
import { PageTransition } from "../components/PageTransition";
import { Button, ModalShell, StatusBadge, Toggle, cx } from "../components/ui";
import type { Lang, ManagedMcpServer, ManagedSkill, SkillsMcpImportPreview, SkillsMcpState } from "../types";
import "../styles/skills-prompts-pages.css";
export type SkillsMcpTab = "mcp" | "skills";
type MaybeAsyncAction = () => void | Promise<void>;
export type SkillsMcpPageProps = {
lang: Lang;
state: SkillsMcpState | null;
activeTab: SkillsMcpTab;
actionBusy: string;
importOpen: boolean;
importPreview: SkillsMcpImportPreview | null;
zipInputRef: RefObject<HTMLInputElement>;
className?: string;
onTabChange: (tab: SkillsMcpTab) => void;
onLoad: MaybeAsyncAction;
onOpenImportPreview: MaybeAsyncAction;
onCloseImportPreview: () => void;
onConfirmImport: MaybeAsyncAction;
onInstallZip: (file?: File | null) => void | Promise<void>;
onCheckUpdates: MaybeAsyncAction;
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
};
type SkillsMcpCopy = ReturnType<typeof getCopy>;
function getCopy(lang: Lang) {
return lang === "zh"
? {
eyebrow: "SKILLS / MCP",
title: "技能和MCP",
description: "管理 Codex 当前可用的 Skills 与 MCP,导入已有内容、安装技能包并控制启用状态。",
refresh: "刷新",
importExisting: "导入已有",
installZip: "从 ZIP 安装",
checkUpdates: "检查更新",
loading: "正在读取本地 Skills / MCP...",
mcpHelp: (count: number) => `当前共有 ${count} 个 MCP,启用后会写入 Codex config.toml。`,
skillsHelp: (count: number) => `当前共有 ${count} 个 Skills,启用后会放入 Codex skills 目录。`,
total: (count: number) => `共 ${count} 个`,
noMcp: "还没有发现 MCP,请先导入已有内容。",
noSkills: "还没有发现 Skills,请导入已有内容或安装 ZIP 技能包。",
enableMcp: "启用 MCP",
disableMcp: "关闭 MCP",
enableSkill: "启用 Skill",
disableSkill: "禁用 Skill",
updateStatus: "更新状态",
importTitle: "确认导入已有内容",
importDescription: "以下内容来自本机现有配置。导入后可在此统一启用或禁用。",
noImportItems: "没有发现可导入的已有 Skills / MCP。",
noImportSkills: "没有可导入的 Skill",
noImportMcp: "没有可导入的 MCP",
cancel: "取消",
importing: "正在导入",
confirmImport: "导入",
warnings: "需要留意",
}
: {
eyebrow: "SKILLS / MCP",
title: "Skills & MCP",
description: "Manage the Skills and MCP servers available to Codex, import existing items, install packages, and control their state.",
refresh: "Refresh",
importExisting: "Import existing",
installZip: "Install ZIP",
checkUpdates: "Check updates",
loading: "Loading local Skills / MCP...",
mcpHelp: (count: number) => `${count} MCP server(s). Enabling one writes it to Codex config.toml.`,
skillsHelp: (count: number) => `${count} Skill(s). Enabling one places it in the Codex skills directory.`,
total: (count: number) => `${count} total`,
noMcp: "No MCP server found. Import existing items first.",
noSkills: "No Skills found. Import existing items or install a ZIP package.",
enableMcp: "Enable MCP",
disableMcp: "Disable MCP",
enableSkill: "Enable Skill",
disableSkill: "Disable Skill",
updateStatus: "Update status",
importTitle: "Confirm import",
importDescription: "These items were found in the existing local configuration. Import them to manage their state here.",
noImportItems: "No existing Skills / MCP items were found.",
noImportSkills: "No Skill to import",
noImportMcp: "No MCP to import",
cancel: "Cancel",
importing: "Importing",
confirmImport: "Import",
warnings: "Review these items",
};
}
function run(action: MaybeAsyncAction) {
void action();
}
function McpRow({
server,
busy,
disabled,
copy,
onToggle,
}: {
server: ManagedMcpServer;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleMcp"];
}) {
const name = server.name || server.id;
const details = [server.summary, server.transport, server.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<Toggle
checked={server.enabled}
onCheckedChange={(enabled) => void onToggle(server.id, enabled)}
disabled={disabled}
aria-label={server.enabled ? copy.disableMcp : copy.enableMcp}
/>
</div>
</article>
);
}
function SkillRow({
skill,
busy,
disabled,
copy,
onToggle,
}: {
skill: ManagedSkill;
busy: boolean;
disabled: boolean;
copy: SkillsMcpCopy;
onToggle: SkillsMcpPageProps["onToggleSkill"];
}) {
const name = skill.name || skill.directory;
const updateTone = skill.updateStatus.includes("失败") || skill.updateStatus.toLowerCase().includes("fail")
? "danger"
: skill.updateStatus.includes("新版本") || skill.updateStatus.toLowerCase().includes("update")
? "warning"
: "neutral";
const showUpdateStatus = Boolean(skill.updateStatus && skill.updateStatus !== "未检查");
const details = [skill.description, skill.source].filter(Boolean).join("\n");
return (
<article className="cx-skills-row">
<div className="cx-skills-row-copy">
<strong title={details ? `${name}\n${details}` : name}>{name}</strong>
{showUpdateStatus && (
<StatusBadge tone={updateTone} dot={false} title={copy.updateStatus}>
{skill.updateStatus}
</StatusBadge>
)}
</div>
<div className="cx-skills-row-control">
{busy && <Loader2 size={15} className="cx-skills-spin" aria-hidden="true" />}
<Toggle
checked={skill.enabled}
onCheckedChange={(enabled) => void onToggle(skill.id, enabled)}
disabled={disabled}
aria-label={skill.enabled ? copy.disableSkill : copy.enableSkill}
/>
</div>
</article>
);
}
function ImportPreviewContent({ preview, copy }: { preview: SkillsMcpImportPreview | null; copy: SkillsMcpCopy }) {
const skillCount = preview?.skills.length ?? 0;
const mcpCount = preview?.mcpServers.length ?? 0;
if (!preview || skillCount + mcpCount === 0) {
return (
<div className="cx-skills-empty cx-skills-empty--compact">
<FolderInput size={22} strokeWidth={1.7} aria-hidden="true" />
<span>{copy.noImportItems}</span>
</div>
);
}
return (
<div className="cx-skills-import-content">
<div className="cx-skills-import-summary" aria-label={`${skillCount} Skills, ${mcpCount} MCP`}>
<div><strong>{skillCount}</strong><span>Skills</span></div>
<div><strong>{mcpCount}</strong><span>MCP</span></div>
</div>
<section className="cx-skills-import-section" aria-labelledby="cx-skills-import-skills">
<div className="cx-skills-import-section-head">
<strong id="cx-skills-import-skills">Skills</strong>
<span>{skillCount}</span>
</div>
<div className="cx-skills-import-list">
{skillCount === 0 ? <p>{copy.noImportSkills}</p> : preview.skills.map((skill) => (
<div className="cx-skills-import-row" key={`skill-${skill.id}-${skill.path}`}>
<strong>{skill.name || skill.directory}</strong>
<span title={skill.path}>{skill.directory}</span>
<em>{skill.source}</em>
</div>
))}
</div>
</section>
<section className="cx-skills-import-section" aria-labelledby="cx-skills-import-mcp">
<div className="cx-skills-import-section-head">
<strong id="cx-skills-import-mcp">MCP</strong>
<span>{mcpCount}</span>
</div>
<div className="cx-skills-import-list">
{mcpCount === 0 ? <p>{copy.noImportMcp}</p> : preview.mcpServers.map((server) => (
<div className="cx-skills-import-row" key={`mcp-${server.id}-${server.source}`}>
<strong>{server.name || server.id}</strong>
<span>{server.transport}</span>
<em>{server.source}</em>
</div>
))}
</div>
</section>
{preview.warnings.length > 0 && (
<section className="cx-skills-import-warnings" aria-label={copy.warnings}>
<strong><AlertCircle size={15} aria-hidden="true" />{copy.warnings}</strong>
{preview.warnings.map((warning, index) => <p key={`${index}-${warning}`}>{warning}</p>)}
</section>
)}
</div>
);
}
export function SkillsMcpPage({
lang,
state,
activeTab,
actionBusy,
importOpen,
importPreview,
zipInputRef,
className,
onTabChange,
onLoad,
onOpenImportPreview,
onCloseImportPreview,
onConfirmImport,
onInstallZip,
onCheckUpdates,
onToggleSkill,
onToggleMcp,
}: SkillsMcpPageProps) {
const copy = getCopy(lang);
const tabId = useId();
const anyBusy = Boolean(actionBusy);
const importBusy = actionBusy === "importExistingSkillsMcp";
const activeItems = activeTab === "mcp" ? state?.mcpServers ?? [] : state?.skills ?? [];
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
void onInstallZip(event.currentTarget.files?.[0]);
};
return (
<section className={cx("cx-skills-page", className)} aria-label={copy.title}>
<header className="cx-skills-header">
<div className="cx-skills-heading">
<p><Blocks size={14} aria-hidden="true" />{copy.eyebrow}</p>
<h2>{copy.title}</h2>
<span>{copy.description}</span>
</div>
<div className="cx-skills-actions">
<input
ref={zipInputRef}
className="cx-skills-file-input"
type="file"
accept=".zip,application/zip"
onChange={handleZipChange}
disabled={anyBusy}
tabIndex={-1}
aria-hidden="true"
/>
<Button
variant="secondary"
icon={actionBusy === "loadSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <RefreshCw />}
onClick={() => run(onLoad)}
disabled={anyBusy}
>
{copy.refresh}
</Button>
<Button
variant="secondary"
icon={actionBusy === "previewExistingSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <Download />}
onClick={() => run(onOpenImportPreview)}
disabled={anyBusy}
>
{copy.importExisting}
</Button>
<Button
variant="secondary"
icon={actionBusy === "installSkillZip" ? <Loader2 className="cx-skills-spin" /> : <Upload />}
onClick={() => zipInputRef.current?.click()}
disabled={anyBusy}
>
{copy.installZip}
</Button>
<Button
icon={actionBusy === "checkSkillUpdates" ? <Loader2 className="cx-skills-spin" /> : <Sparkles />}
onClick={() => run(onCheckUpdates)}
disabled={anyBusy}
>
{copy.checkUpdates}
</Button>
</div>
</header>
{!state ? (
<div className="cx-skills-empty cx-skills-empty--loading" role="status">
<Loader2 size={23} className="cx-skills-spin" aria-hidden="true" />
<span>{copy.loading}</span>
</div>
) : (
<>
<div className="cx-skills-tabs" role="tablist" aria-label="Skills and MCP">
<button
id={`${tabId}-mcp-tab`}
type="button"
role="tab"
aria-selected={activeTab === "mcp"}
aria-controls={`${tabId}-mcp-panel`}
className={cx("cx-skills-tab", activeTab === "mcp" && "cx-skills-tab--active")}
onClick={() => onTabChange("mcp")}
>
MCP <span>{state.mcpServers.length}</span>
</button>
<button
id={`${tabId}-skills-tab`}
type="button"
role="tab"
aria-selected={activeTab === "skills"}
aria-controls={`${tabId}-skills-panel`}
className={cx("cx-skills-tab", activeTab === "skills" && "cx-skills-tab--active")}
onClick={() => onTabChange("skills")}
>
Skills <span>{state.skills.length}</span>
</button>
</div>
<p className="cx-skills-help">
<span aria-hidden="true" />
{activeTab === "mcp" ? copy.mcpHelp(state.mcpServers.length) : copy.skillsHelp(state.skills.length)}
</p>
<PageTransition pageKey={`skills-mcp:${activeTab}`}>
<section
id={`${tabId}-${activeTab}-panel`}
role="tabpanel"
aria-labelledby={`${tabId}-${activeTab}-tab`}
className="cx-skills-list-panel"
>
<div className="cx-skills-list-head">
<div>
{activeTab === "mcp" ? <PlugZap size={17} aria-hidden="true" /> : <PackageOpen size={17} aria-hidden="true" />}
<h3>{activeTab === "mcp" ? "MCP" : "Skills"}</h3>
</div>
<span>{copy.total(activeItems.length)}</span>
</div>
<div className="cx-skills-list">
{activeTab === "mcp" ? (
state.mcpServers.length === 0 ? (
<div className="cx-skills-empty"><PlugZap size={22} aria-hidden="true" /><span>{copy.noMcp}</span></div>
) : state.mcpServers.map((server) => (
<McpRow
key={server.id}
server={server}
busy={actionBusy === `mcp:${server.id}`}
disabled={anyBusy}
copy={copy}
onToggle={onToggleMcp}
/>
))
) : state.skills.length === 0 ? (
<div className="cx-skills-empty"><PackageOpen size={22} aria-hidden="true" /><span>{copy.noSkills}</span></div>
) : state.skills.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
busy={actionBusy === `skill:${skill.id}`}
disabled={anyBusy}
copy={copy}
onToggle={onToggleSkill}
/>
))}
</div>
</section>
</PageTransition>
{state.warnings.length > 0 && (
<section className="cx-skills-warnings" aria-label={copy.warnings}>
{state.warnings.map((warning, index) => (
<p key={`${index}-${warning}`}><AlertCircle size={15} aria-hidden="true" />{warning}</p>
))}
</section>
)}
</>
)}
<ModalShell
open={importOpen}
onClose={() => {
if (!importBusy) onCloseImportPreview();
}}
title={copy.importTitle}
description={copy.importDescription}
size="lg"
closeOnBackdrop={!importBusy}
closeOnEscape={!importBusy}
showCloseButton={!importBusy}
className="cx-skills-import-modal"
bodyClassName="cx-skills-import-modal-body"
footer={(
<>
<Button variant="secondary" onClick={onCloseImportPreview} disabled={importBusy}>
{copy.cancel}
</Button>
<Button
icon={importBusy ? <Loader2 className="cx-skills-spin" /> : <Download />}
onClick={() => run(onConfirmImport)}
disabled={importBusy || !importPreview || importPreview.skills.length + importPreview.mcpServers.length === 0}
>
{importBusy ? copy.importing : copy.confirmImport}
</Button>
</>
)}
>
<ImportPreviewContent preview={importPreview} copy={copy} />
</ModalShell>
</section>
);
}
+310
View File
@@ -0,0 +1,310 @@
import type { ReactNode } from "react";
import {
CheckCircle2,
Download,
ExternalLink,
Globe2,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import "../styles/utility-pages.css";
export type UtilityLanguage = "zh" | "en";
export type UtilityStatusTone = "neutral" | "success" | "warning" | "error";
type PageHeaderProps = {
eyebrow: ReactNode;
title: ReactNode;
description?: ReactNode;
aside?: ReactNode;
};
function PageHeader({ eyebrow, title, description, aside }: PageHeaderProps) {
return (
<header className="cx-page-header">
<div className="cx-page-header-copy">
<div className="cx-page-eyebrow">{eyebrow}</div>
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
{aside}
</header>
);
}
export type TomlConfigPageProps = {
eyebrow: ReactNode;
title: ReactNode;
description: ReactNode;
loaded: ReactNode;
isLoaded: boolean;
preview: ReactNode;
};
export function TomlConfigPage({
eyebrow,
title,
description,
loaded,
isLoaded,
preview,
}: TomlConfigPageProps) {
return (
<section className="cx-utility cx-page cx-page--toml">
<PageHeader
eyebrow={eyebrow}
title={title}
description={description}
aside={(
<div className={`cx-page-header-status${isLoaded ? "" : " cx-page-header-status--missing"}`} aria-live="polite">
<span className="cx-page-status-dot" aria-hidden="true" />
<span>{loaded}</span>
</div>
)}
/>
<section className="cx-page-panel cx-page-code-panel">
<div className="cx-page-code-frame">{preview}</div>
</section>
</section>
);
}
export type SettingsCopy = {
eyebrow: ReactNode;
title: ReactNode;
languageTitle: ReactNode;
languageDescription: ReactNode;
chineseLabel: ReactNode;
englishLabel: ReactNode;
productTitle: ReactNode;
productDescription: ReactNode;
productValue: ReactNode;
recheckTitle: ReactNode;
recheckDescription: ReactNode;
recheckLabel: ReactNode;
};
export type SettingsPageProps = {
lang: UtilityLanguage;
copy: SettingsCopy;
onLanguageChange: (lang: UtilityLanguage) => void;
onRecheck: () => void;
recheckBusy?: boolean;
};
type SettingRowProps = {
icon: LucideIcon;
title: ReactNode;
description: ReactNode;
action: ReactNode;
};
function SettingRow({ icon: Icon, title, description, action }: SettingRowProps) {
return (
<div className="cx-page-setting-row">
<div className="cx-page-setting-icon" aria-hidden="true">
<Icon size={18} strokeWidth={1.9} />
</div>
<div className="cx-page-setting-copy">
<strong>{title}</strong>
<p>{description}</p>
</div>
<div className="cx-page-setting-action">{action}</div>
</div>
);
}
export function SettingsPage({
lang,
copy,
onLanguageChange,
onRecheck,
recheckBusy = false,
}: SettingsPageProps) {
return (
<section className="cx-utility cx-page cx-page--settings">
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
<div className="cx-page-settings-list">
<SettingRow
icon={Globe2}
title={copy.languageTitle}
description={copy.languageDescription}
action={(
<div className="cx-page-segmented" role="group" aria-label={String(copy.languageTitle)}>
<button
type="button"
className={lang === "zh" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
onClick={() => onLanguageChange("zh")}
aria-pressed={lang === "zh"}
>
{copy.chineseLabel}
</button>
<button
type="button"
className={lang === "en" ? "cx-page-segmented-button cx-page-segmented-button--active" : "cx-page-segmented-button"}
onClick={() => onLanguageChange("en")}
aria-pressed={lang === "en"}
>
{copy.englishLabel}
</button>
</div>
)}
/>
<SettingRow
icon={Sparkles}
title={copy.productTitle}
description={copy.productDescription}
action={<span className="cx-page-value-pill">{copy.productValue}</span>}
/>
<SettingRow
icon={CheckCircle2}
title={copy.recheckTitle}
description={copy.recheckDescription}
action={(
<button
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={onRecheck}
disabled={recheckBusy}
>
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
{copy.recheckLabel}
</button>
)}
/>
</div>
</section>
);
}
export type AboutCopy = {
eyebrow: ReactNode;
title: ReactNode;
appVersionLabel: ReactNode;
codexVersionLabel: ReactNode;
codexHomeLabel: ReactNode;
projectLabel: ReactNode;
openProjectLabel: ReactNode;
openIssuesLabel: ReactNode;
releasesEyebrow: ReactNode;
releasesTitle: ReactNode;
releaseStatusLabel: ReactNode;
latestVersionLabel: ReactNode;
checkUpdateLabel: ReactNode;
openReleasesLabel: ReactNode;
};
export type AboutReleaseState = {
status: ReactNode;
latestVersion: ReactNode;
tone?: UtilityStatusTone;
checking?: boolean;
canOpenReleases?: boolean;
};
export type AboutPageProps = {
copy: AboutCopy;
appVersion: ReactNode;
codexVersion: ReactNode;
codexHome: ReactNode;
projectUrl: ReactNode;
release: AboutReleaseState;
onOpenProject: () => void;
onOpenIssues: () => void;
onCheckUpdate: () => void;
onOpenReleases: () => void;
};
type InfoRowProps = {
label: ReactNode;
value: ReactNode;
mono?: boolean;
};
function InfoRow({ label, value, mono = false }: InfoRowProps) {
return (
<div className="cx-page-info-row">
<span>{label}</span>
<strong className={mono ? "cx-page-info-value cx-page-info-value--mono" : "cx-page-info-value"}>{value}</strong>
</div>
);
}
export function AboutPage({
copy,
appVersion,
codexVersion,
codexHome,
projectUrl,
release,
onOpenProject,
onOpenIssues,
onCheckUpdate,
onOpenReleases,
}: AboutPageProps) {
const releaseTone = release.tone || "neutral";
const releaseStatusClass = `cx-page-release-status cx-page-release-status--${releaseTone}`;
return (
<section className="cx-utility cx-page cx-page--about">
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
<section className="cx-page-panel cx-page-about-panel">
<div className="cx-page-info-list">
<InfoRow label={copy.appVersionLabel} value={appVersion} />
<InfoRow label={copy.codexVersionLabel} value={codexVersion} />
<InfoRow label={copy.codexHomeLabel} value={codexHome} mono />
<InfoRow label={copy.projectLabel} value={projectUrl} mono />
</div>
<div className="cx-page-panel-actions">
<button type="button" className="cx-page-button cx-page-button--secondary" onClick={onOpenProject}>
<ExternalLink size={15} aria-hidden="true" />
{copy.openProjectLabel}
</button>
<button type="button" className="cx-page-button cx-page-button--secondary" onClick={onOpenIssues}>
<ExternalLink size={15} aria-hidden="true" />
{copy.openIssuesLabel}
</button>
</div>
</section>
<section className="cx-page-panel cx-page-release-panel">
<div className="cx-page-release-header">
<div>
<div className="cx-page-eyebrow cx-page-eyebrow--muted">{copy.releasesEyebrow}</div>
<h3>{copy.releasesTitle}</h3>
</div>
<span className={releaseStatusClass} aria-live="polite">{release.status}</span>
</div>
<div className="cx-page-info-list">
<InfoRow label={copy.releaseStatusLabel} value={release.status} />
<InfoRow label={copy.latestVersionLabel} value={release.latestVersion} />
</div>
<div className="cx-page-panel-actions">
<button
type="button"
className="cx-page-button cx-page-button--primary"
onClick={onCheckUpdate}
disabled={release.checking}
>
{release.checking ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
{copy.checkUpdateLabel}
</button>
<button
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={onOpenReleases}
disabled={release.canOpenReleases === false}
>
<Download size={15} aria-hidden="true" />
{copy.openReleasesLabel}
</button>
</div>
</section>
</section>
);
}
+240
View File
@@ -0,0 +1,240 @@
import * as React from "react";
import type { Lang } from "./types";
const STORAGE_KEY = "codexx.promptCategories.v1";
const STATE_VERSION = 1 as const;
export const PROMPT_CATEGORY_NAME_MAX_LENGTH = 40;
export type PromptCategory = {
id: string;
name: string;
};
type PromptCategoryState = {
version: typeof STATE_VERSION;
categories: PromptCategory[];
defaultCategoryId: string;
activeCategoryId: string;
assignments: Record<string, string>;
};
function defaultCategories(lang: Lang): PromptCategory[] {
return lang === "zh"
? [
{ id: "security-reverse", name: "破甲/逆向" },
{ id: "software-development", name: "软件开发" },
{ id: "writing", name: "写作辅助" },
]
: [
{ id: "security-reverse", name: "Security / reverse" },
{ id: "software-development", name: "Software development" },
{ id: "writing", name: "Writing" },
];
}
function initialState(lang: Lang): PromptCategoryState {
const categories = defaultCategories(lang);
return {
version: STATE_VERSION,
categories,
defaultCategoryId: categories[0].id,
activeCategoryId: categories[0].id,
assignments: {},
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function sanitizePromptCategoryState(value: unknown, lang: Lang): PromptCategoryState {
const fallback = initialState(lang);
if (!isRecord(value) || value.version !== STATE_VERSION || !Array.isArray(value.categories)) {
return fallback;
}
const categories: PromptCategory[] = [];
const seenIds = new Set<string>();
const seenNames = new Set<string>();
for (const candidate of value.categories) {
if (!isRecord(candidate)) continue;
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
const nameKey = name.toLocaleLowerCase();
if (!id || !name || seenIds.has(id) || seenNames.has(nameKey)) continue;
seenIds.add(id);
seenNames.add(nameKey);
categories.push({ id, name });
}
if (!categories.length) return fallback;
const requestedDefault = typeof value.defaultCategoryId === "string" ? value.defaultCategoryId : "";
const defaultCategoryId = seenIds.has(requestedDefault) ? requestedDefault : categories[0].id;
const requestedActive = typeof value.activeCategoryId === "string" ? value.activeCategoryId : "";
const activeCategoryId = seenIds.has(requestedActive) ? requestedActive : defaultCategoryId;
const assignments: Record<string, string> = {};
if (isRecord(value.assignments)) {
for (const [promptKey, categoryId] of Object.entries(value.assignments)) {
if (promptKey.trim() && typeof categoryId === "string" && seenIds.has(categoryId)) {
assignments[promptKey] = categoryId;
}
}
}
return {
version: STATE_VERSION,
categories,
defaultCategoryId,
activeCategoryId,
assignments,
};
}
function loadState(lang: Lang): PromptCategoryState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return initialState(lang);
return sanitizePromptCategoryState(JSON.parse(raw), lang);
} catch {
return initialState(lang);
}
}
function saveState(state: PromptCategoryState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
return true;
} catch {
return false;
}
}
function nextCategoryId(categories: PromptCategory[]) {
const used = new Set(categories.map((category) => category.id));
const base = `category-${Date.now().toString(36)}`;
let id = base;
let suffix = 2;
while (used.has(id)) {
id = `${base}-${suffix}`;
suffix += 1;
}
return id;
}
export function usePromptCategories(lang: Lang) {
const [state, setState] = React.useState<PromptCategoryState>(() => loadState(lang));
React.useEffect(() => {
saveState(state);
}, [state]);
const categoryIds = React.useMemo(
() => new Set(state.categories.map((category) => category.id)),
[state.categories],
);
const categoryForPrompt = React.useCallback(
(promptKey: string) => {
const assigned = state.assignments[promptKey];
return assigned && categoryIds.has(assigned) ? assigned : state.defaultCategoryId;
},
[categoryIds, state.assignments, state.defaultCategoryId],
);
const setActiveCategoryId = React.useCallback((categoryId: string) => {
setState((current) => current.categories.some((category) => category.id === categoryId)
? { ...current, activeCategoryId: categoryId }
: current);
}, []);
const addCategory = React.useCallback((name: string) => {
const trimmed = name.trim();
if (!trimmed
|| trimmed.length > PROMPT_CATEGORY_NAME_MAX_LENGTH
|| state.categories.some((category) => category.name.toLocaleLowerCase() === trimmed.toLocaleLowerCase())) {
return false;
}
const category: PromptCategory = { id: nextCategoryId(state.categories), name: trimmed };
setState((current) => ({
...current,
categories: [...current.categories, category],
activeCategoryId: category.id,
}));
return true;
}, [state.categories]);
const renameCategory = React.useCallback((categoryId: string, name: string) => {
const trimmed = name.trim();
if (!trimmed || trimmed.length > PROMPT_CATEGORY_NAME_MAX_LENGTH || state.categories.some((category) =>
category.id !== categoryId && category.name.toLocaleLowerCase() === trimmed.toLocaleLowerCase())) {
return false;
}
setState((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId ? { ...category, name: trimmed } : category),
}));
return true;
}, [state.categories]);
const deleteCategory = React.useCallback((categoryId: string) => {
if (state.categories.length <= 1 || !state.categories.some((category) => category.id === categoryId)) {
return false;
}
setState((current) => {
const categories = current.categories.filter((category) => category.id !== categoryId);
const defaultCategoryId = current.defaultCategoryId === categoryId
? categories[0].id
: current.defaultCategoryId;
const assignments = Object.fromEntries(
Object.entries(current.assignments).map(([promptKey, assignedCategoryId]) => [
promptKey,
assignedCategoryId === categoryId ? defaultCategoryId : assignedCategoryId,
]),
);
return {
...current,
categories,
defaultCategoryId,
activeCategoryId: current.activeCategoryId === categoryId
? defaultCategoryId
: current.activeCategoryId,
assignments,
};
});
return true;
}, [state.categories]);
const movePrompt = React.useCallback((promptKey: string, categoryId: string) => {
if (!promptKey || !categoryIds.has(categoryId)) return;
setState((current) => {
const assignments = { ...current.assignments };
if (categoryId === current.defaultCategoryId) delete assignments[promptKey];
else assignments[promptKey] = categoryId;
return { ...current, assignments };
});
}, [categoryIds]);
const forgetPrompt = React.useCallback((promptKey: string) => {
if (!promptKey) return;
setState((current) => {
if (!(promptKey in current.assignments)) return current;
const assignments = { ...current.assignments };
delete assignments[promptKey];
return { ...current, assignments };
});
}, []);
return {
categories: state.categories,
activeCategoryId: state.activeCategoryId,
categoryForPrompt,
setActiveCategoryId,
addCategory,
renameCategory,
deleteCategory,
movePrompt,
forgetPrompt,
};
}
File diff suppressed because it is too large Load Diff
+352
View File
@@ -0,0 +1,352 @@
.cx-app-toast {
position: fixed;
top: 18px;
right: 0;
left: 0;
z-index: 1200;
width: max-content;
max-width: min(560px, calc(100vw - 32px));
display: flex;
align-items: center;
gap: 10px;
margin-inline: auto;
padding: 12px 24px;
border: 1px solid rgba(34, 197, 94, 0.34);
border-radius: 10px;
color: #303640;
background: #ffffff;
box-shadow: 0 12px 28px rgba(25, 32, 44, 0.14), 0 2px 7px rgba(25, 32, 44, 0.08);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
pointer-events: none;
animation:
cx-app-toast-enter 180ms ease-out both,
cx-app-toast-exit 180ms ease-in 3s forwards;
}
.cx-app-shell--mac .cx-app-toast { top: 52px; }
.cx-app-toast--error {
border-color: rgba(239, 68, 68, 0.34);
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.13), 0 4px 14px rgba(25, 32, 44, 0.1);
}
.cx-app-toast--loading {
border-color: rgba(59, 130, 246, 0.34);
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.13), 0 4px 14px rgba(25, 32, 44, 0.1);
animation: cx-app-toast-enter 180ms ease-out both;
}
.cx-app-toast-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
color: #22c55e;
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.13);
animation: cx-app-toast-pulse 1.4s ease-in-out infinite;
}
.cx-app-toast--error .cx-app-toast-dot {
color: #ef4444;
background: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.13);
}
.cx-app-toast-loader {
flex: 0 0 auto;
color: #3b82f6;
animation: cx-app-toast-spin 0.8s linear infinite;
}
.cx-app-toast-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.cx-app-toast-copy strong {
color: inherit;
font-size: 14px;
font-weight: 640;
line-height: 1.4;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.cx-app-toast-copy span {
max-height: 64px;
overflow: auto;
color: #6b7280;
font-size: 12px;
line-height: 1.45;
letter-spacing: 0;
white-space: pre-wrap;
}
@keyframes cx-app-toast-enter {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cx-app-toast-exit {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes cx-app-toast-spin {
to { transform: rotate(360deg); }
}
@keyframes cx-app-toast-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.65; transform: scale(0.88); }
}
.cx-update-dialog-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
margin-bottom: 14px;
border: 1px solid color-mix(in srgb, var(--ui-accent) 24%, var(--ui-border));
border-radius: 9px;
color: var(--ui-accent);
background: var(--ui-accent-soft);
}
.cx-update-dialog-icon--ready {
color: var(--ui-success);
background: color-mix(in srgb, var(--ui-success) 10%, var(--ui-surface-elevated));
border-color: color-mix(in srgb, var(--ui-success) 28%, var(--ui-border));
}
.cx-update-dialog-icon--error {
color: var(--ui-danger);
background: var(--ui-danger-soft);
border-color: color-mix(in srgb, var(--ui-danger) 28%, var(--ui-border));
}
.cx-update-version-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 0;
}
.cx-update-version-grid div {
min-width: 0;
padding: 12px;
border: 1px solid #e2e5e9;
border-radius: 8px;
background: #f7f8fa;
}
.cx-update-version-grid dt {
color: #777d86;
font-size: 11px;
font-weight: 620;
}
.cx-update-version-grid dd {
margin: 5px 0 0;
overflow: hidden;
color: #24272d;
font-size: 14px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-update-progress,
.cx-update-notes {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface-elevated);
}
.cx-update-progress-copy {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 9px;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 1.4;
}
.cx-update-progress-copy strong {
color: var(--ui-text);
font-size: 11px;
font-weight: 650;
text-align: right;
}
.cx-update-progress-track {
position: relative;
height: 7px;
overflow: hidden;
border-radius: 999px;
background: var(--ui-surface-muted);
}
.cx-update-progress-track > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--ui-accent);
transition: width 160ms ease-out;
}
.cx-update-progress-track--indeterminate > span {
animation: cx-update-progress-indeterminate 1.1s ease-in-out infinite;
}
.cx-update-notes > strong {
display: block;
margin-bottom: 6px;
color: var(--ui-text);
font-size: 12px;
font-weight: 680;
}
.cx-update-notes p {
max-height: 104px;
margin: 0;
overflow: auto;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
}
@keyframes cx-update-progress-indeterminate {
from { transform: translateX(-110%); }
to { transform: translateX(300%); }
}
.cx-startup-dialog {
transition: opacity 220ms ease, transform 220ms ease;
}
.cx-startup-dialog--closing {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
.cx-startup-path-control {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
margin-bottom: 14px;
padding: 7px;
border: 1px solid #e2e5e9;
border-radius: 8px;
background: #f7f8fa;
}
.cx-startup-path-control label {
padding-left: 5px;
color: #777d86;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 650;
}
.cx-startup-path-control input {
min-width: 0;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e5e9;
border-radius: 7px;
outline: none;
color: #424851;
background: #ffffff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.cx-startup-path-control input:focus {
border-color: rgba(37, 99, 235, 0.5);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.cx-startup-checks {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.cx-startup-check {
min-width: 0;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
gap: 9px;
padding: 11px;
border: 1px solid #ead6a9;
border-radius: 8px;
background: #fff9eb;
}
.cx-startup-check--ok {
border-color: #cce8d9;
background: #f0faf4;
}
.cx-startup-check--manual {
border-color: #d7e1f5;
background: #f4f7fd;
}
.cx-startup-check-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 7px;
color: #9b6818;
background: rgba(255, 255, 255, 0.72);
}
.cx-startup-check--ok .cx-startup-check-icon { color: #14804a; }
.cx-startup-check--manual .cx-startup-check-icon { color: #2563eb; }
.cx-startup-check strong {
color: #30343a;
font-size: 12px;
}
.cx-startup-check p {
margin: 3px 0 0;
color: #717780;
font-size: 11px;
line-height: 1.4;
}
.cx-startup-check code {
display: block;
margin-top: 5px;
overflow: hidden;
color: #59616c;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 680px) {
.cx-startup-path-control { grid-template-columns: 1fr auto; }
.cx-startup-path-control label { grid-column: 1 / -1; }
.cx-startup-checks { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.cx-update-progress-track--indeterminate > span {
animation: none;
transform: translateX(80%);
}
}
+611
View File
@@ -0,0 +1,611 @@
.cx-app-shell,
.cx-app-shell * {
box-sizing: border-box;
}
:root[data-theme="light"],
:root[data-theme="light"] body {
color: #202226;
background: #f3f4f6;
}
.cx-app-shell {
--cx-titlebar-height: 0px;
--cx-sidebar-width: 256px;
width: 100vw;
height: 100vh;
min-width: 1180px;
min-height: 720px;
display: grid;
grid-template-columns: var(--cx-sidebar-width) minmax(0, 1fr);
overflow: hidden;
color: #202226;
background: #f3f4f6;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
.cx-app-shell--mac {
--cx-titlebar-height: 44px;
}
.cx-window-drag-region {
position: fixed;
inset: 0 0 auto;
z-index: 1000;
height: var(--cx-titlebar-height);
background: rgba(248, 249, 250, 0.98);
border-bottom: 1px solid #e7e9ed;
user-select: none;
-webkit-user-select: none;
-webkit-app-region: drag;
}
.cx-sidebar {
position: relative;
z-index: 2;
min-width: 0;
min-height: 0;
height: 100vh;
display: flex;
flex-direction: column;
padding: calc(var(--cx-titlebar-height) + 16px) 14px 14px;
overflow: hidden;
color: #44484f;
background: rgba(255, 255, 255, 0.92);
border-right: 1px solid #e2e5e9;
}
.cx-brand {
min-height: 54px;
display: flex;
align-items: center;
gap: 11px;
padding: 5px 7px 13px;
margin-bottom: 10px;
}
.cx-brand-mark {
flex: 0 0 38px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 8px;
color: #ffffff;
background: #2563eb;
box-shadow: 0 5px 14px rgba(37, 99, 235, 0.2);
font-size: 20px;
font-weight: 780;
line-height: 1;
}
.cx-brand-copy {
flex: 1 1 auto;
min-width: 0;
}
.ui-icon-button.cx-brand-update {
position: relative;
width: 36px;
height: 36px;
margin-left: auto;
color: #059669;
background: transparent;
border-color: transparent;
border-radius: 8px;
box-shadow: none;
}
.ui-icon-button.cx-brand-update:hover:not(:disabled) {
color: #047857;
background: #ecfdf5;
border-color: transparent;
transform: none;
}
.ui-icon-button.cx-brand-update:active:not(:disabled) {
background: #d1fae5;
transform: none;
}
.ui-icon-button.cx-brand-update svg {
width: 20px;
height: 20px;
transition: transform 160ms ease;
}
.ui-icon-button.cx-brand-update:hover:not(:disabled) svg {
transform: translateY(-1px);
}
.cx-brand-update[data-update-state="available"]::after {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
content: "";
border: 2px solid #ffffff;
border-radius: 50%;
background: #10b981;
animation: cx-brand-update-pulse 1.8s ease-out infinite;
}
.cx-brand-update[data-update-state="downloading"] svg,
.cx-brand-update[data-update-state="installing"] svg {
animation: cx-brand-update-spin 900ms linear infinite;
}
.cx-brand-title-row {
display: flex;
align-items: baseline;
gap: 7px;
}
.cx-brand h1 {
margin: 0;
color: #181a1f;
font-size: 19px;
font-weight: 730;
line-height: 1.2;
letter-spacing: 0;
}
.cx-brand p {
margin: 3px 0 0;
overflow: hidden;
color: #898e97;
font-size: 10px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-app-version {
color: #969ba4;
font-size: 11px;
font-weight: 650;
line-height: 1;
}
.cx-sidebar-nav {
min-height: 0;
display: grid;
align-content: start;
gap: 4px;
}
.cx-nav-item {
position: relative;
width: 100%;
height: 44px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 11px;
padding: 0 11px;
overflow: hidden;
border: 1px solid transparent;
border-radius: 9px;
color: #656a73;
background: transparent;
box-shadow: none;
font-size: 13px;
font-weight: 540;
line-height: 1;
text-align: left;
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
}
.cx-nav-item:hover {
color: #20242a;
background: #f4f5f7;
}
.cx-nav-item:focus-visible,
.cx-brand-update:focus-visible,
.cx-theme-button:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.48);
outline-offset: 2px;
}
.cx-nav-item--active {
color: #1557d5;
background: #edf4ff;
border-color: #d5e4ff;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.07);
font-weight: 650;
}
.cx-nav-item--active:hover {
color: #1557d5;
background: #eaf2ff;
}
.cx-nav-item svg {
position: relative;
z-index: 1;
flex: 0 0 auto;
color: #8a9099;
}
.cx-nav-item--active svg {
color: #2563eb;
}
.cx-nav-active-mark {
position: absolute;
top: 50%;
left: -1px;
width: 4px;
height: 20px;
border-radius: 0 3px 3px 0;
background: transparent;
transform: translateY(-50%);
}
.cx-nav-item--active .cx-nav-active-mark {
background: #2563eb;
}
.cx-nav-label {
position: relative;
z-index: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-sidebar-footer {
display: grid;
gap: 7px;
margin-top: auto;
padding-top: 14px;
border-top: 1px solid #eceef1;
}
.cx-codex-version,
.cx-theme-button {
width: 100%;
min-width: 0;
min-height: 42px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 7px 10px;
border: 1px solid transparent;
border-radius: 7px;
color: #70757d;
background: transparent;
text-align: left;
}
.cx-codex-version svg,
.cx-theme-button svg {
color: #8c929b;
}
.cx-codex-version span {
min-width: 0;
display: grid;
gap: 2px;
}
.cx-codex-version small {
color: #969ba4;
font-size: 10px;
font-weight: 560;
line-height: 1.1;
}
.cx-codex-version strong {
overflow: hidden;
color: #4b5058;
font-size: 11px;
font-weight: 650;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-theme-button {
grid-template-columns: 18px minmax(0, 1fr) 36px;
cursor: pointer;
font-size: 12px;
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
}
.cx-theme-button:hover {
color: #262a30;
background: #f4f5f7;
border-color: #e6e8ec;
}
.cx-theme-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.cx-theme-copy span {
overflow: hidden;
color: #555a63;
font-size: 11px;
font-weight: 620;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-theme-copy strong {
overflow: hidden;
color: #969ba4;
font-size: 11px;
font-weight: 560;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-theme-switch {
position: relative;
width: 36px;
height: 20px;
padding: 2px;
border: 1px solid #d5d8dd;
border-radius: 999px;
background: #e4e6e9;
box-shadow: inset 0 1px 2px rgba(32, 34, 38, 0.08);
transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.cx-theme-switch-thumb {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 3px rgba(20, 24, 31, 0.28);
transform: translateX(0);
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cx-theme-button[aria-checked="true"] .cx-theme-switch {
border-color: #2563eb;
background: #2563eb;
box-shadow: inset 0 1px 2px rgba(23, 65, 162, 0.22), 0 0 0 1px rgba(37, 99, 235, 0.08);
}
.cx-theme-button[aria-checked="true"] .cx-theme-switch-thumb {
transform: translateX(16px);
}
.cx-app-main {
position: relative;
z-index: 1;
min-width: 0;
min-height: 0;
height: 100vh;
padding-top: var(--cx-titlebar-height);
overflow: hidden;
}
.cx-app-content {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding: 24px 28px 28px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.cx-app-content-inner {
width: min(100%, 1240px);
min-width: 0;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 18px;
margin: 0 auto;
}
.cx-page-transition {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
transform-origin: center top;
}
.cx-page-transition--exit,
.cx-page-transition--enter {
will-change: opacity, transform;
}
.cx-page-transition--exit {
pointer-events: none;
animation: cx-page-exit 250ms cubic-bezier(0.4, 0, 1, 1) both;
}
.cx-page-transition--enter {
animation: cx-page-enter 250ms cubic-bezier(0, 0, 0.2, 1) both;
}
.cx-app-content--sessions,
.cx-app-content--fixed,
.cx-app-content--modal-locked {
overflow-y: hidden;
}
.cx-app-content--sessions .cx-app-content-inner,
.cx-app-content--fixed .cx-app-content-inner {
height: 100%;
}
.cx-app-content::-webkit-scrollbar {
width: 9px;
}
.cx-app-content::-webkit-scrollbar-track {
background: transparent;
}
.cx-app-content::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 5px;
background: #c9cdd3;
background-clip: padding-box;
}
.cx-app-content::-webkit-scrollbar-thumb:hover {
background: #aeb3bb;
background-clip: padding-box;
}
html.cx-theme-view-transition::view-transition-old(root) {
animation: cx-theme-view-old 240ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
html.cx-theme-view-transition::view-transition-new(root) {
animation: cx-theme-view-new 260ms cubic-bezier(0.2, 0, 0, 1) both;
}
html.cx-theme-fallback-transition body,
html.cx-theme-fallback-transition body *,
html.cx-theme-fallback-transition body *::before,
html.cx-theme-fallback-transition body *::after {
transition-property: color, background-color, border-color, box-shadow, fill, stroke;
transition-duration: 220ms;
transition-timing-function: ease;
}
@media (max-width: 1100px) {
.cx-app-shell {
--cx-sidebar-width: 78px;
}
.cx-sidebar {
padding-right: 10px;
padding-left: 10px;
}
.cx-brand {
justify-content: center;
padding-right: 0;
padding-left: 0;
}
.cx-brand-copy,
.cx-nav-label,
.cx-codex-version span,
.cx-theme-copy,
.cx-theme-switch {
display: none;
}
.cx-nav-item,
.cx-codex-version,
.cx-theme-button {
grid-template-columns: 1fr;
justify-items: center;
padding-right: 0;
padding-left: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cx-nav-item,
.cx-brand-update,
.cx-brand-update svg,
.cx-theme-button,
.cx-theme-switch,
.cx-theme-switch-thumb {
transition: none;
}
.cx-brand-update[data-update-state="available"]::after,
.cx-brand-update[data-update-state="downloading"] svg,
.cx-brand-update[data-update-state="installing"] svg {
animation: none;
}
.cx-page-transition--exit,
.cx-page-transition--enter {
animation: none;
}
html.cx-theme-view-transition::view-transition-old(root),
html.cx-theme-view-transition::view-transition-new(root) {
animation: none;
}
}
@keyframes cx-brand-update-pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.38);
}
70%,
100% {
box-shadow: 0 0 0 5px rgba(16, 185, 129, 0);
}
}
@keyframes cx-brand-update-spin {
to {
transform: rotate(360deg);
}
}
@keyframes cx-theme-view-old {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes cx-theme-view-new {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cx-page-exit {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-15px) scale(0.99);
}
}
@keyframes cx-page-enter {
from {
opacity: 0;
transform: translateY(15px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
:root[data-theme="dark"] .cx-theme-copy span {
color: #c6c9cf;
}
:root[data-theme="dark"] .cx-theme-button .cx-theme-copy strong {
color: #858a93;
}
:root[data-theme="dark"] .cx-theme-button[aria-checked="false"] .cx-theme-switch {
border-color: #444850;
background: #34373e;
}
+133
View File
@@ -0,0 +1,133 @@
:root {
color-scheme: light;
color: #202226;
background: #f3f4f6;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}
body {
overflow: hidden;
background: #f3f4f6;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.spin {
animation: cx-base-spin 1s linear infinite;
}
.page-pane-hidden {
display: none !important;
}
.toml-preview {
width: max-content;
min-width: 100%;
margin: 0;
padding: 14px 0;
color: #39404a;
background: #fafbfc;
font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 500;
line-height: 1.65;
letter-spacing: 0;
tab-size: 2;
}
.toml-line {
width: 100%;
min-height: 20px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
padding-right: 14px;
}
.toml-line:hover {
background: #f3f6fa;
}
.toml-line-no {
padding-right: 12px;
color: #9aa1ab;
font-size: 11px;
text-align: right;
user-select: none;
}
.toml-line code {
min-width: max-content;
color: #39404a;
font-family: inherit;
white-space: pre;
}
.toml-key {
color: #242a33;
font-weight: 620;
}
.toml-eq {
padding: 0 4px;
color: #7d858f;
}
.toml-string {
color: #16845a;
}
.toml-bool {
color: #7046b5;
font-weight: 680;
}
.toml-section {
color: #1d4ed8;
font-weight: 720;
}
.toml-comment {
color: #828993;
font-style: italic;
}
@keyframes cx-base-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spin {
animation: none;
}
}
File diff suppressed because it is too large Load Diff
+458
View File
@@ -0,0 +1,458 @@
.cx-overview-page,
.cx-overview-page * {
box-sizing: border-box;
}
.cx-overview-page {
--cx-overview-ink: #202226;
--cx-overview-muted: #777d86;
--cx-overview-subtle: #969ba4;
--cx-overview-line: #e2e5e9;
--cx-overview-panel: rgba(255, 255, 255, 0.88);
--cx-overview-blue: #2563eb;
--cx-overview-green: #14804a;
min-width: 0;
display: grid;
gap: 18px;
color: var(--cx-overview-ink);
}
.cx-overview-header {
min-width: 0;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 4px 0 20px;
border-bottom: 1px solid var(--cx-overview-line);
}
.cx-overview-heading {
min-width: 0;
}
.cx-overview-eyebrow,
.cx-overview-section-label {
margin: 0;
color: var(--cx-overview-blue);
font-size: 11px;
font-weight: 720;
letter-spacing: 0.13em;
line-height: 1.25;
text-transform: uppercase;
}
.cx-overview-eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 7px;
}
.cx-overview-live-dot,
.cx-overview-update-dot {
display: inline-block;
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--cx-overview-blue);
}
.cx-overview-live-dot {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11);
}
.cx-overview-heading h2 {
max-width: min(620px, 46vw);
margin: 0;
overflow: hidden;
color: #181a1f;
font-size: clamp(24px, 2.25vw, 34px);
font-weight: 740;
letter-spacing: 0;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-overview-home-control {
min-width: min(480px, 48%);
display: grid;
grid-template-columns: auto minmax(150px, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px;
border: 1px solid var(--cx-overview-line);
border-radius: 10px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(32, 34, 38, 0.04);
}
.cx-overview-home-control label {
padding: 0 7px;
color: var(--cx-overview-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.cx-overview-home-control input {
width: 100%;
min-width: 0;
height: 34px;
padding: 0 10px;
border: 1px solid #e8eaed;
border-radius: 7px;
outline: none;
color: #4b5058;
background: #f7f8f9;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
}
.cx-overview-home-control input::placeholder {
color: #a6abb3;
}
.cx-overview-home-control input:focus {
border-color: rgba(37, 99, 235, 0.5);
background: #ffffff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.cx-overview-home-control button,
.cx-overview-update-strip button {
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid #1d4ed8;
border-radius: 7px;
padding: 0 13px;
color: #ffffff;
background: var(--cx-overview-blue);
font-size: 12px;
font-weight: 680;
white-space: nowrap;
transition: background-color 140ms ease, border-color 140ms ease, opacity 140ms ease;
}
.cx-overview-home-control button:hover:not(:disabled),
.cx-overview-update-strip button:hover:not(:disabled) {
border-color: #1e40af;
background: #1d4ed8;
}
.cx-overview-home-control button:focus-visible,
.cx-overview-update-strip button:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.38);
outline-offset: 2px;
}
.cx-overview-home-control button:disabled,
.cx-overview-update-strip button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.cx-overview-spin {
animation: cx-overview-spin 0.9s linear infinite;
}
@keyframes cx-overview-spin {
to { transform: rotate(360deg); }
}
.cx-overview-update-strip {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #cfe0ff;
border-radius: 10px;
color: #21498f;
background: #f3f7ff;
}
.cx-overview-update-copy {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 10px;
}
.cx-overview-update-dot {
margin-top: 6px;
background: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
}
.cx-overview-update-copy strong {
display: block;
color: #1e3a70;
font-size: 13px;
font-weight: 720;
line-height: 1.3;
}
.cx-overview-update-copy p {
margin: 3px 0 0;
color: #5b709a;
font-size: 12px;
line-height: 1.35;
}
.cx-overview-update-strip button {
flex: 0 0 auto;
}
.cx-overview-status-grid {
min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.cx-overview-status-card {
min-width: 0;
min-height: 132px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 16px;
border: 1px solid var(--cx-overview-line);
border-radius: 10px;
background: var(--cx-overview-panel);
box-shadow: 0 2px 8px rgba(32, 34, 38, 0.025);
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}
.cx-overview-status-card:hover {
border-color: #cdd4df;
box-shadow: 0 6px 16px rgba(32, 34, 38, 0.06);
transform: translateY(-1px);
}
.cx-overview-status-icon {
flex: 0 0 auto;
width: 40px;
height: 40px;
display: grid;
place-items: center;
border: 1px solid #e1e5ea;
border-radius: 9px;
color: #727983;
background: #f4f5f7;
}
.cx-overview-status-card--success .cx-overview-status-icon {
border-color: #ccebdc;
color: var(--cx-overview-green);
background: #eefaf3;
}
.cx-overview-status-card--active .cx-overview-status-icon {
border-color: #cfe0ff;
color: var(--cx-overview-blue);
background: #f0f5ff;
}
.cx-overview-status-copy {
min-width: 0;
display: grid;
gap: 5px;
}
.cx-overview-status-copy span {
overflow: hidden;
color: var(--cx-overview-muted);
font-size: 11px;
font-weight: 600;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-overview-status-copy strong {
overflow: hidden;
color: #262a30;
font-size: 14px;
font-weight: 710;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-overview-config-panel {
min-width: 0;
padding: 0;
overflow: hidden;
border: 1px solid var(--cx-overview-line);
border-radius: 10px;
background: var(--cx-overview-panel);
box-shadow: 0 2px 8px rgba(32, 34, 38, 0.025);
}
.cx-overview-panel-heading {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 0;
padding: 16px 18px;
border-bottom: 1px solid var(--cx-overview-line);
background: #fafbfc;
}
.cx-overview-panel-heading h3 {
margin: 4px 0 0;
color: #24272d;
font-size: 18px;
font-weight: 710;
line-height: 1.25;
}
.cx-overview-instruction-pill {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 29px;
padding: 0 10px;
border: 1px solid #e1e4e8;
border-radius: 999px;
color: #7b818a;
background: #f6f7f8;
font-size: 11px;
font-weight: 680;
white-space: nowrap;
}
.cx-overview-instruction-pill--active {
border-color: #ccebdc;
color: var(--cx-overview-green);
background: #eefaf3;
}
.cx-overview-config-list {
padding: 16px 18px 18px;
min-width: 0;
display: grid;
gap: 7px;
}
.cx-overview-config-row {
min-width: 0;
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
align-items: center;
gap: 12px;
min-height: 42px;
padding: 7px 10px;
border: 1px solid transparent;
border-radius: 7px;
background: #f7f8f9;
transition: background-color 140ms ease, border-color 140ms ease;
}
.cx-overview-config-row:hover {
border-color: #e4e7eb;
background: #f3f4f6;
}
.cx-overview-config-row > span {
color: var(--cx-overview-muted);
font-size: 12px;
font-weight: 620;
}
.cx-overview-config-row code {
min-width: 0;
overflow: hidden;
color: #515862;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 980px) {
.cx-overview-header {
align-items: stretch;
flex-direction: column;
}
.cx-overview-heading h2 {
max-width: 100%;
}
.cx-overview-home-control {
width: 100%;
min-width: 0;
}
.cx-overview-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.cx-overview-status-grid {
grid-template-columns: minmax(0, 1fr);
}
.cx-overview-home-control {
grid-template-columns: 1fr auto;
}
.cx-overview-home-control label {
grid-column: 1 / -1;
padding: 2px 4px 0;
}
.cx-overview-home-control input {
grid-column: 1;
}
.cx-overview-home-control button {
grid-column: 2;
}
.cx-overview-update-strip {
align-items: flex-start;
flex-direction: column;
}
.cx-overview-update-strip button {
align-self: flex-end;
}
.cx-overview-panel-heading {
align-items: stretch;
flex-direction: column;
}
.cx-overview-instruction-pill {
align-self: flex-start;
}
.cx-overview-config-row {
grid-template-columns: 1fr;
gap: 3px;
}
.cx-overview-config-row code {
overflow-wrap: anywhere;
white-space: normal;
}
}
+784
View File
@@ -0,0 +1,784 @@
.cx-providers,
.cx-providers * {
box-sizing: border-box;
}
.cx-providers {
--cx-provider-text: #20242a;
--cx-provider-muted: #747b85;
--cx-provider-border: #e0e4e9;
--cx-provider-border-soft: #eceef1;
--cx-provider-blue: #2563eb;
--cx-provider-blue-soft: #edf3ff;
width: 100%;
min-height: 100%;
color: var(--cx-provider-text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
.cx-providers--list {
height: calc(100vh - var(--cx-titlebar-height, 0px) - 52px);
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.cx-providers-header,
.cx-providers-form-header {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 2px 1px 19px;
margin-bottom: 18px;
border-bottom: 1px solid var(--cx-provider-border-soft);
}
.cx-providers-header-copy,
.cx-providers-form-header > div {
min-width: 0;
}
.cx-providers-header {
align-items: flex-end;
}
.cx-providers-eyebrow {
margin: 0 0 6px;
color: var(--cx-provider-blue);
font-size: 10px;
font-weight: 760;
line-height: 1.25;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cx-providers-header h2,
.cx-providers-form-header h2 {
margin: 0;
color: var(--cx-provider-text);
font-size: 25px;
font-weight: 720;
line-height: 1.2;
letter-spacing: 0;
}
.cx-providers-header p,
.cx-providers-form-header p {
max-width: 780px;
margin: 8px 0 0;
color: var(--cx-provider-muted);
font-size: 13px;
line-height: 1.6;
}
.cx-providers-header-actions,
.cx-providers-form-actions,
.cx-providers-row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.cx-providers-header-actions,
.cx-providers-form-header > .cx-providers-button {
flex: 0 0 auto;
}
.cx-providers-button {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 15px;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
font-size: 13px;
font-weight: 650;
line-height: 1;
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease, opacity 140ms ease;
}
.cx-providers-button:focus-visible,
.cx-providers-icon-button:focus-visible,
.cx-providers-secret-input button:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.42);
outline-offset: 2px;
}
.cx-providers-button:disabled,
.cx-providers-icon-button:disabled,
.cx-providers-secret-input button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.cx-providers-button--primary {
color: #ffffff;
background: var(--cx-provider-blue);
border-color: var(--cx-provider-blue);
}
.cx-providers-button--primary:hover:not(:disabled) {
background: #1d4ed8;
border-color: #1d4ed8;
}
.cx-providers-button--dark {
color: #ffffff;
background: #202226;
border-color: #202226;
box-shadow: 0 4px 10px rgba(24, 26, 31, 0.12);
}
.cx-providers-button--dark:hover:not(:disabled) {
background: #101216;
border-color: #101216;
}
.cx-providers-button--secondary {
color: #4a515b;
background: #ffffff;
border-color: var(--cx-provider-border);
box-shadow: 0 2px 6px rgba(32, 34, 38, 0.06);
}
.cx-providers-button--secondary:hover:not(:disabled) {
color: #20242a;
background: #f7f8fa;
border-color: #cbd2da;
}
.cx-providers-button--small {
min-height: 34px;
padding: 0 12px;
font-size: 12px;
}
.cx-providers-list {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: grid;
align-content: start;
gap: 8px;
padding-right: 6px;
padding-bottom: 8px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.cx-providers-list::-webkit-scrollbar {
width: 8px;
}
.cx-providers-list::-webkit-scrollbar-track {
background: transparent;
}
.cx-providers-list::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 5px;
background: #c9cdd3;
background-clip: padding-box;
}
.cx-providers-list::-webkit-scrollbar-thumb:hover {
background: #aeb3bb;
background-clip: padding-box;
}
.cx-providers-row {
min-width: 0;
min-height: 78px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--cx-provider-border);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(27, 35, 48, 0.06);
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.cx-providers-row:hover {
border-color: #c9d5e7;
box-shadow: 0 5px 16px rgba(27, 35, 48, 0.07);
}
.cx-providers-row--current {
border-color: #93c5fd;
background: #eff6ff;
box-shadow: 0 0 0 1px rgba(147, 197, 253, 0.5), 0 3px 10px rgba(37, 99, 235, 0.08);
}
.cx-providers-avatar {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid #dce3ee;
border-radius: 11px;
color: #586273;
background: #f1f4f8;
font-size: 16px;
font-weight: 720;
}
.cx-providers-avatar--official {
color: #20242a;
background: #f4f5f7;
border-color: #dde1e6;
}
.cx-providers-openai-logo {
width: 22px;
height: 22px;
display: block;
background: currentColor;
-webkit-mask: url("/openai.svg") center / contain no-repeat;
mask: url("/openai.svg") center / contain no-repeat;
}
.cx-providers-avatar--current,
.cx-providers-avatar--official.cx-providers-avatar--current {
color: #ffffff;
background: var(--cx-provider-blue);
border-color: var(--cx-provider-blue);
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2);
}
.cx-providers-row-main {
min-width: 0;
}
.cx-providers-row-title {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
}
.cx-providers-row-title strong {
min-width: 0;
overflow: hidden;
color: var(--cx-provider-text);
font-size: 14px;
font-weight: 680;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-providers-row-main code {
display: block;
max-width: 100%;
margin-top: 4px;
overflow: hidden;
color: #727b87;
font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-providers-row-meta {
margin-top: 5px;
color: #8a919b;
font-size: 11px;
line-height: 1.35;
}
.cx-providers-current-badge,
.cx-providers-source-badge {
min-height: 22px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 7px;
border: 1px solid #c9dcff;
border-radius: 5px;
color: #1d5fc5;
background: #edf4ff;
font-size: 11px;
font-weight: 680;
white-space: nowrap;
}
.cx-providers-current-badge > span {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.cx-providers-source-badge {
border-color: var(--cx-provider-border);
color: #747c87;
background: #f4f5f7;
}
.cx-providers-source-badge--official {
border-color: #bae6fd;
color: #0369a1;
background: #e0f2fe;
font-size: 10px;
font-weight: 720;
}
.cx-providers-row-actions {
justify-content: flex-end;
}
.cx-providers-icon-button {
width: 34px;
height: 34px;
display: inline-grid;
place-items: center;
padding: 0;
border: 1px solid var(--cx-provider-border);
border-radius: 9px;
cursor: pointer;
color: #69717d;
background: #ffffff;
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
}
.cx-providers-icon-button:hover:not(:disabled) {
color: var(--cx-provider-blue);
background: #f4f7ff;
border-color: #cbdcff;
}
.cx-providers-icon-button--danger {
color: #b42342;
}
.cx-providers-icon-button--danger:hover:not(:disabled) {
color: #a51c3b;
background: #fff2f4;
border-color: #f0cbd4;
}
.ui-modal.cx-provider-delete-dialog {
width: min(440px, calc(100vw - 40px));
border-color: color-mix(in srgb, var(--ui-danger) 30%, var(--ui-border));
}
.cx-provider-delete-dialog-body {
display: grid;
gap: 12px;
}
.cx-provider-delete-warning {
min-width: 0;
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: center;
gap: 11px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--ui-danger) 25%, transparent);
border-radius: 8px;
color: var(--ui-danger);
background: var(--ui-danger-soft);
}
.cx-provider-delete-warning > span {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--ui-danger) 12%, transparent);
}
.cx-provider-delete-warning strong {
min-width: 0;
overflow: hidden;
color: var(--ui-text);
font-size: 14px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-providers-empty {
min-height: 160px;
display: grid;
place-items: center;
padding: 24px;
border: 1px dashed #cfd5dc;
border-radius: 8px;
color: var(--cx-provider-muted);
background: #fbfcfd;
font-size: 13px;
}
.cx-providers-form-header {
align-items: flex-start;
}
.cx-providers-info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.cx-providers-info-grid > div {
min-width: 0;
display: grid;
gap: 5px;
padding: 11px 12px;
border: 1px solid var(--cx-provider-border);
border-radius: 8px;
background: #ffffff;
}
.cx-providers-info-grid span {
color: var(--cx-provider-muted);
font-size: 11px;
font-weight: 600;
}
.cx-providers-info-grid code {
min-width: 0;
overflow: hidden;
color: #4d5865;
font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-providers-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px;
margin-bottom: 14px;
}
.cx-providers-form-grid--single {
grid-template-columns: minmax(0, 1fr);
}
.cx-providers-form-grid--provider {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.cx-providers-field--full,
.cx-providers-checkbox--full {
grid-column: 1 / -1;
}
.cx-providers-form-section {
min-width: 0;
margin-bottom: 14px;
padding: 15px;
border: 1px solid var(--cx-provider-border);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 3px 12px rgba(27, 35, 48, 0.035);
}
.cx-providers-section-heading {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
}
.cx-providers-section-heading--with-action {
align-items: center;
}
.cx-providers-section-heading h3 {
margin: 0;
color: var(--cx-provider-text);
font-size: 14px;
font-weight: 690;
line-height: 1.3;
}
.cx-providers-section-heading p {
margin: 5px 0 0;
color: var(--cx-provider-muted);
font-size: 11px;
line-height: 1.5;
}
.cx-providers-field {
min-width: 0;
display: grid;
gap: 6px;
}
.cx-providers-field > span {
color: #59616c;
font-size: 11px;
font-weight: 660;
}
.cx-providers-field input,
.cx-providers-field select,
.cx-providers-field textarea,
.cx-providers-secret-input input {
width: 100%;
min-width: 0;
border: 1px solid var(--cx-provider-border);
border-radius: 6px;
outline: none;
color: #303640;
background: #ffffff;
font-size: 12px;
}
.cx-providers-field input,
.cx-providers-field select,
.cx-providers-secret-input input {
height: 38px;
padding: 0 10px;
}
.cx-providers-model-input-row {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 7px;
}
.cx-providers-fetch-models {
min-height: 38px;
white-space: nowrap;
}
.cx-providers-model-select {
cursor: pointer;
}
.cx-providers-field input:focus,
.cx-providers-field select:focus,
.cx-providers-field textarea:focus,
.cx-providers-secret-input:focus-within {
border-color: #9db9f7;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.09);
}
.cx-providers-secret-input {
min-width: 0;
position: relative;
display: flex;
align-items: center;
border: 1px solid var(--cx-provider-border);
border-radius: 6px;
background: #ffffff;
}
.cx-providers-secret-input input {
border: 0;
box-shadow: none;
}
.cx-providers-secret-input button {
width: 36px;
height: 36px;
display: grid;
place-items: center;
flex: 0 0 36px;
border: 0;
color: #7d858f;
background: transparent;
cursor: pointer;
}
.cx-providers-secret-input button:hover {
color: var(--cx-provider-blue);
}
.cx-providers-checkbox {
min-height: 38px;
display: flex;
align-items: center;
align-self: end;
gap: 8px;
color: var(--cx-provider-muted);
font-size: 11px;
font-weight: 630;
}
.cx-providers-checkbox .ui-checkbox__box {
margin-top: 0;
}
.cx-providers-checkbox .ui-checkbox__label {
color: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.4;
}
.cx-providers-preview {
min-width: 0;
max-height: 240px;
overflow: auto;
border: 1px solid var(--cx-provider-border-soft);
border-radius: 6px;
background: #fafbfc;
}
.cx-providers-preview .toml-preview {
min-height: 0;
max-height: 220px;
margin: 0;
border: 0;
border-radius: 0;
background: #fafbfc;
color: #39404a;
box-shadow: none;
}
.cx-providers-preview .toml-line:hover {
background: #f1f5f9;
}
.cx-providers-preview .toml-line-no {
color: #98a2b1;
}
.cx-providers-preview .toml-line code,
.cx-providers-preview .toml-key {
color: #27303c;
}
.cx-providers-preview .toml-eq {
color: #687386;
}
.cx-providers-preview .toml-string {
color: #b42342;
}
.cx-providers-preview .toml-bool {
color: #7047b8;
}
.cx-providers-preview .toml-section {
color: #1d5fc5;
}
.cx-providers-preview .toml-comment {
color: #687386;
}
.cx-providers-code-editor {
width: 100%;
min-height: 220px;
resize: vertical;
padding: 12px;
border: 1px solid var(--cx-provider-border);
border-radius: 6px;
outline: none;
color: #39404a;
background: #fafbfc;
font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.6;
tab-size: 2;
}
.cx-providers-auth-editor {
overflow: auto;
white-space: pre;
overflow-wrap: normal;
}
.cx-providers-toml-editor {
min-height: 360px;
}
.cx-providers-form-actions {
padding: 2px 0 4px;
}
.cx-providers-form-actions--save {
justify-content: flex-end;
}
.cx-providers-spin {
animation: cx-providers-spin 1s linear infinite;
}
@keyframes cx-providers-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 980px) {
.cx-providers-header,
.cx-providers-form-header {
flex-direction: column;
}
.cx-providers-header-actions {
width: 100%;
}
.cx-providers-info-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.cx-providers-row {
grid-template-columns: 40px minmax(0, 1fr);
}
.cx-providers-row-actions {
grid-column: 2;
justify-content: flex-start;
}
.cx-providers-form-grid {
grid-template-columns: 1fr;
}
.cx-providers-field--full,
.cx-providers-checkbox--full {
grid-column: auto;
}
.cx-providers-section-heading--with-action {
align-items: flex-start;
flex-direction: column;
}
.cx-providers-model-input-row {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.cx-providers-button,
.cx-providers-icon-button,
.cx-providers-row {
transition: none;
}
.cx-providers-spin {
animation: none;
}
}

Some files were not shown because too many files have changed in this diff Show More