Compare commits

..
5 Commits
Author SHA1 Message Date
yyyyyynnn 9687a4b11c release: prepare v0.3.5 2026-07-28 19:50:41 +08:00
yyyyyynnn 32bab7731e release: prepare v0.3.4 2026-07-28 18:14:20 +08:00
yyyyyynnn f64848ca9c release: prepare v0.3.3 2026-07-26 11:54:32 +08:00
yyyyyynnn 94ab211431 release: prepare v0.3.2 2026-07-26 00:00:39 +08:00
yyyyyynnn e8b0e5b73c ci: stabilize updater release URLs 2026-07-24 16:06:57 +08:00
43 changed files with 2661 additions and 217 deletions
+9 -3
View File
@@ -460,6 +460,8 @@ jobs:
--manifest latest.json \
--assets release-assets.json \
--version "$VERSION" \
--repository "$GITHUB_REPOSITORY" \
--release-tag "$RELEASE_TAG" \
--rewrite-download-urls \
--require-signature-assets
gh release upload "$RELEASE_TAG" latest.json --clobber
@@ -498,7 +500,9 @@ jobs:
python3 scripts/validate_updater_release.py \
--manifest published-latest.json \
--assets release-assets.json \
--version "$VERSION"
--version "$VERSION" \
--repository "$GITHUB_REPOSITORY" \
--release-tag "$RELEASE_TAG"
python3 - <<'PY' > updater-urls.txt
import json
@@ -507,9 +511,11 @@ jobs:
print(entry["url"])
PY
sort -u updater-urls.txt | while read -r url; do
curl -fsSLI \
curl -fsSL \
--range 0-0 \
--retry 5 \
--retry-delay 3 \
--retry-all-errors \
"$url" >/dev/null
"$url" \
-o /dev/null
done
+46
View File
@@ -2,6 +2,52 @@
All notable changes to Codex-X will be documented here.
## [v0.3.5] - 2026-07-28
### 更新
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
### 修复 Bug
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
## [v0.3.4] - 2026-07-28
### 更新
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
### 修复 Bug
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
## [v0.3.3] - 2026-07-26
### 调整
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
## [v0.3.2] - 2026-07-25
### 修复 Bug
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
### 安全
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
## [v0.3.1] - 2026-07-24
### 更新
+32 -2
View File
@@ -147,7 +147,7 @@ You can use it to:
### 1. Prompt Template Center
<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/Template_library-11_total-2563eb?style=flat-square" alt="11 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>
@@ -155,7 +155,7 @@ You can use it to:
> [!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.
> The app bundle includes 5 templates for offline use. After launch, it synchronizes 6 additional software-development and writing templates, plus later updates, 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>
@@ -189,6 +189,36 @@ You can use it to:
<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>
<tr>
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
<td align="left">Long-term maintenance with reuse, minimal changes, safety, testing, and maintainability</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
<td align="left">Evidence-driven debugging from reliable reproduction to root-cause repair and regression checks</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
<td align="left">Severity-ranked review of defects, regressions, security risks, and test gaps</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
<td align="left">Chinese and English editing that preserves the author's meaning and facts</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
<td align="left">Source-grounded README, guide, API, architecture, and release documentation</td>
<td align="center">GitHub sync</td>
</tr>
<tr>
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
<td align="left">Turns scattered material into a structured report, proposal, retrospective, or article draft</td>
<td align="center">GitHub sync</td>
</tr>
</table>
</div>
+32 -2
View File
@@ -127,7 +127,7 @@
### 1. 可视化提示词注入中心
<p align="center">
<img src="https://img.shields.io/badge/当前模板库-5_套-2563eb?style=flat-square" alt="当前模板库 5 套" />
<img src="https://img.shields.io/badge/当前模板库-11_套-2563eb?style=flat-square" alt="当前模板库 11 套" />
<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="支持自定义提示词" />
@@ -136,7 +136,7 @@
> [!TIP]
> **安装后就能用,联网后自动补齐,也能维护自己的提示词库。**
>
> 安装包离线自带当前全部 5 套模板;软件启动后可同步 GitHub `examples/` 的更新和新增模板。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
> 安装包离线自带 5 套模板;软件启动后会从 GitHub `examples/` 同步另外 6 套软件开发与写作辅助模板,以及后续更新。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可视化提示词注入与管理工具:
@@ -178,6 +178,36 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
<td align="left">中文技术操作员人格,覆盖 coding、CTF、逆向、内存与协议任务路由</td>
<td align="center">离线内置<br />GitHub 更新</td>
</tr>
<tr>
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
<td align="left">长期维护正式项目,强调复用、最小改动、安全、测试与可维护性</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
<td align="left">从稳定复现和证据采集推进到根因修复与回归验证</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
<td align="left">按严重级别审查缺陷、回归、安全风险和测试缺口</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
<td align="left">在保留原意和事实的前提下润色中英文表达</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
<td align="left">基于代码与事实编写 README、指南、API 和发布文档</td>
<td align="center">GitHub 在线同步</td>
</tr>
<tr>
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
<td align="left">将零散材料组织成报告、方案、复盘或文章初稿</td>
<td align="center">GitHub 在线同步</td>
</tr>
</table>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.1",
"version": "0.3.5",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -346,7 +346,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.1"
version = "0.3.5"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.1"
version = "0.3.5"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -8,10 +8,18 @@ import { readImageMetadata } from "./image-metadata.mjs";
const scriptPath = fileURLToPath(import.meta.url);
const here = path.dirname(scriptPath);
const root = path.resolve(here, "..");
const SKIN_VERSION = "1.2.3";
const SKIN_VERSION = "1.2.4";
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
const CDP_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
const MAX_ART_BYTES = 16 * 1024 * 1024;
const CODEX_SURFACE_SELECTORS = Object.freeze({
shell: "main.main-surface",
sidebar: "aside.app-shell-left-panel",
composer: ".composer-surface-chrome",
home: '[role="main"]:has([data-testid="home-icon"])',
appearance: 'input[name="appearance-theme"]',
themePreview: '[data-testid="theme-preview"]',
});
let staticPayloadAssets = null;
function parseArgs(argv) {
@@ -203,29 +211,36 @@ async function listAppTargets(port) {
}
}
async function probeSession(session) {
export async function probeSession(session) {
return session.evaluate(`(() => {
const markers = {
shell: Boolean(document.querySelector('main.main-surface')),
sidebar: Boolean(document.querySelector('aside.app-shell-left-panel')),
composer: Boolean(document.querySelector('.composer-surface-chrome')),
main: Boolean(document.querySelector('[role="main"]')),
shell: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)})),
sidebar: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)})),
composer: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.composer)})),
main: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)})),
settings: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)})) ||
Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)})),
};
return {
title: document.title,
href: location.href,
markers,
codex: markers.shell && markers.sidebar,
codex: location.protocol === 'app:' &&
((markers.shell && markers.sidebar) || markers.main || markers.settings),
};
})()`);
}
async function waitForCodexProbe(session, timeoutMs = 1800) {
export async function waitForCodexProbe(session, timeoutMs = 1800) {
const deadline = Date.now() + timeoutMs;
let probe = null;
while (Date.now() < deadline) {
probe = await probeSession(session);
if (probe?.codex) return probe;
try {
probe = await probeSession(session);
if (probe?.codex) return probe;
} catch {
// Chromium can briefly replace the execution context while Codex boots.
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
return probe;
@@ -246,7 +261,8 @@ async function connectCodexTargets(port, timeoutMs) {
let session;
try {
session = await connectTarget(target, port);
const probe = await probeSession(session);
const remainingMs = Math.max(50, deadline - Date.now());
const probe = await waitForCodexProbe(session, Math.min(1800, remainingMs));
if (probe?.codex) connected.push({ target, session, probe });
else session.close();
} catch (error) {
@@ -255,7 +271,7 @@ async function connectCodexTargets(port, timeoutMs) {
}
}
if (connected.length) return connected;
lastError = new Error("No page matched the expected Codex shell markers");
lastError = new Error("No page matched the expected Codex surface markers");
} catch (error) {
lastError = error;
}
@@ -517,7 +533,7 @@ async function verifyRemovedSession(session) {
)()`);
}
async function verifySession(session) {
export async function verifySession(session) {
return session.evaluate(`(() => {
const box = (node) => {
if (!node) return null;
@@ -533,6 +549,8 @@ async function verifySession(session) {
const homeSignal = homeIndicator ?? document.querySelector('[data-feature="game-source"]') ??
document.querySelector('.group\\\\/home-suggestions');
const homeRoute = homeSignal?.closest('[role="main"]') ?? null;
const settingsAnchor = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
const home = document.querySelector('[role="main"].dream-skin-home');
const suggestions = home?.querySelector('.group\\\\/home-suggestions') ?? null;
const cardBoxes = suggestions ? [...suggestions.querySelectorAll('button')].map(box) : [];
@@ -550,7 +568,10 @@ async function verifySession(session) {
chromePresent: Boolean(chrome),
chromePointerEvents: getComputedStyle(chrome || document.body).pointerEvents,
homeRoute: Boolean(homeRoute),
homeRouteBox: box(homeRoute),
homePresent: Boolean(home),
settingsRoute: Boolean(settingsAnchor),
settingsAnchor: box(settingsAnchor),
hero,
cards: cardBoxes,
visibleCardCount: visibleCards.length,
@@ -564,9 +585,13 @@ async function verifySession(session) {
y: document.documentElement.scrollHeight > document.documentElement.clientHeight,
},
};
const standardSurface = Boolean(result.shell?.visible && result.sidebar?.visible);
const routeSurface = Boolean(result.homeRouteBox?.visible || result.settingsAnchor?.visible);
const chromePass = result.settingsRoute ||
(result.chromePresent && result.chromePointerEvents === 'none');
const basePass = result.installed && result.version === ${JSON.stringify(SKIN_VERSION)} &&
result.stylePresent && result.chromePresent && result.chromePointerEvents === 'none' &&
Boolean(result.shell?.visible) && Boolean(result.sidebar?.visible) && !result.documentOverflow.x;
result.stylePresent && chromePass && (standardSurface || routeSurface) &&
!result.documentOverflow.x;
// Project selector markup varies across Codex builds — soft requirement.
const homePass = !result.homeRoute || (
result.homePresent && result.hero?.visible && result.hero.width >= 280 && result.hero.height >= 120
@@ -688,9 +713,13 @@ export function earlyPayloadFor(payload, revision) {
const install = () => {
if (window[generationKey] !== generation) { stop(); return true; }
if (!document.documentElement) return false;
const shell = document.querySelector('main.main-surface');
const sidebar = document.querySelector('aside.app-shell-left-panel');
if (!shell || !sidebar) return false;
if (location.protocol !== "app:") return false;
const shell = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)});
const sidebar = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)});
const home = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)});
const settings = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
if (!(shell && sidebar) && !home && !settings) return false;
stop();
${payload};
window[appliedKey] = generation;
@@ -3,24 +3,33 @@ import fs from "node:fs/promises";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
import { earlyPayloadFor } from "../scripts/injector.mjs";
import {
earlyPayloadFor,
probeSession,
verifySession,
waitForCodexProbe,
} from "../scripts/injector.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
const source = await fs.readFile(injectorPath, "utf8");
function createFixture() {
function createFixture(protocol = "app:") {
const observers = [];
const timers = new Map();
let nextTimer = 1;
const markers = { shell: false, sidebar: false };
const markers = { shell: false, sidebar: false, home: false, settings: false, themePreview: false };
const context = {
window: { installs: [] },
location: { protocol },
document: {
documentElement: {},
querySelector(selector) {
if (selector === "main.main-surface") return markers.shell ? {} : null;
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
if (selector === '[role="main"]:has([data-testid="home-icon"])') return markers.home ? {} : null;
if (selector === 'input[name="appearance-theme"]') return markers.settings ? {} : null;
if (selector === '[data-testid="theme-preview"]') return markers.themePreview ? {} : null;
return null;
},
},
@@ -50,6 +59,26 @@ guarded.markers.shell = true;
guarded.observers[0].callback([]);
assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient.");
const home = createFixture();
home.markers.home = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("home")', "home"), home.context);
assert.deepEqual(home.context.window.installs, ["home"], "The explicit Codex home route must support early injection.");
const settings = createFixture();
settings.markers.settings = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("settings")', "settings"), settings.context);
assert.deepEqual(
settings.context.window.installs,
["settings"],
"The Codex appearance route must support early injection.",
);
const web = createFixture("https:");
web.markers.shell = true;
web.markers.sidebar = true;
vm.runInNewContext(earlyPayloadFor('window.installs.push("web")', "web"), web.context);
assert.deepEqual(web.context.window.installs, [], "Web pages must remain untouched even with spoofed shell markers.");
const generations = createFixture();
vm.runInNewContext(earlyPayloadFor('window.installs.push("old")', "old"), generations.context);
vm.runInNewContext(earlyPayloadFor('window.installs.push("new")', "new"), generations.context);
@@ -63,9 +92,123 @@ assert.deepEqual(
);
assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "new");
function createProbeSession(frames) {
let callCount = 0;
return {
get callCount() { return callCount; },
async evaluate(expression) {
const frame = frames[Math.min(callCount, frames.length - 1)];
callCount += 1;
if (frame instanceof Error) throw frame;
const marker = (key) => frame[key] ? {} : null;
return vm.runInNewContext(expression, {
location: { protocol: frame.protocol ?? "app:", href: `${frame.protocol ?? "app:"}//codex` },
document: {
title: "Codex",
querySelector(selector) {
if (selector === "main.main-surface") return marker("shell");
if (selector === "aside.app-shell-left-panel") return marker("sidebar");
if (selector === ".composer-surface-chrome") return marker("composer");
if (selector === '[role="main"]:has([data-testid="home-icon"])') return marker("home");
if (selector === 'input[name="appearance-theme"]') return marker("settings");
if (selector === '[data-testid="theme-preview"]') return marker("themePreview");
return null;
},
},
});
},
};
}
assert.equal((await probeSession(createProbeSession([{ shell: true, sidebar: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{ home: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{ settings: true }]))).codex, true);
assert.equal((await probeSession(createProbeSession([{}]))).codex, false, "An unmarked app page must be rejected.");
assert.equal(
(await probeSession(createProbeSession([{ protocol: "https:", shell: true, sidebar: true }]))).codex,
false,
"A non-app page must be rejected even when it spoofs the standard shell.",
);
const delayed = createProbeSession([
new Error("Execution context was destroyed"),
{},
{ home: true },
]);
assert.equal((await waitForCodexProbe(delayed, 300))?.codex, true);
assert.equal(delayed.callCount, 3, "Probe waiting must survive document replacement and delayed DOM markers.");
function createVerificationSession(route) {
const node = (width = 640, height = 480) => ({
getBoundingClientRect: () => ({ x: 0, y: 0, width, height }),
querySelector: () => null,
});
const body = node();
const homeHero = node(560, 240);
const home = node();
home.firstElementChild = { firstElementChild: { firstElementChild: homeHero } };
const homeIndicator = { closest: () => home };
const settingsAnchor = node(320, 48);
const chrome = node();
return {
async evaluate(expression) {
return vm.runInNewContext(expression, {
innerWidth: 1280,
innerHeight: 800,
window: { __CODEX_DREAM_SKIN_STATE__: { version: "1.2.4" } },
getComputedStyle(target) {
return { display: "block", visibility: "visible", pointerEvents: target === chrome ? "none" : "auto" };
},
document: {
body,
documentElement: {
classList: { contains: () => true },
scrollWidth: 1280,
clientWidth: 1280,
scrollHeight: 800,
clientHeight: 800,
},
getElementById(id) {
if (id === "codex-dream-skin-style") return {};
if (id === "codex-dream-skin-chrome" && route === "home") return chrome;
return null;
},
querySelector(selector) {
if (selector === '[data-testid="home-icon"]' && route === "home") return homeIndicator;
if (selector === '[role="main"].dream-skin-home' && route === "home") return home;
if (selector === 'input[name="appearance-theme"]' && route === "settings") return settingsAnchor;
return null;
},
},
});
},
};
}
assert.equal(
(await verifySession(createVerificationSession("home"))).pass,
true,
"A verified Codex home route must not require the legacy shell/sidebar pair.",
);
assert.equal(
(await verifySession(createVerificationSession("settings"))).pass,
true,
"A verified appearance route must accept the installed root theme before the main shell mounts.",
);
assert.equal(
(await verifySession(createVerificationSession("auxiliary"))).pass,
false,
"An auxiliary app page must not pass post-install verification.",
);
const discoveryStart = source.indexOf("record.earlyScriptId = await registerEarly");
const probeStart = source.indexOf("const probe = await waitForCodexProbe", discoveryStart);
assert.ok(discoveryStart >= 0 && probeStart > discoveryStart, "Early registration must happen before full shell probing.");
assert.match(
source,
/connectCodexTargets[\s\S]*const probe = await waitForCodexProbe/,
"One-shot discovery must wait for a progressively loaded Codex renderer.",
);
assert.match(
source,
/finally\s*\{[\s\S]*Promise\.all\(\[\.\.\.sessions\.values\(\)\][\s\S]*removeEarly\(record\)/,
@@ -82,4 +225,4 @@ assert.match(
"One-shot commands must exit after flushing output so a lingering CDP close handshake cannot block Codex-X.",
);
console.log("PASS: early injection is shell-guarded, generation-safe, and removed on shutdown.");
console.log("PASS: renderer probing is route-aware, delayed-load safe, generation-safe, and guarded.");
+6
View File
@@ -71,6 +71,12 @@ pub(crate) fn open() -> Result<Connection> {
checked_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS builtin_prompt_overrides (
template_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_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,
+71 -8
View File
@@ -48,12 +48,13 @@ use file_io::{
use paths::app_home;
use paths::home_dir;
use prompts::{
agents_path, builtin_prompt_content, builtin_prompt_status_inner, bundled_prompt_meta,
delete_prompt_inner, get_saved_prompt_inner, install_managed_agents_block,
agents_path, builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
bundled_prompt_meta, delete_prompt_inner, get_saved_prompt_inner, install_managed_agents_block,
list_saved_prompts_inner, managed_agents_bounds, normalize_prompt_filename,
prompt_template_key_for_instruction, refresh_builtin_prompts_with_active,
remember_current_instruction_prompt, resolve_instruction_path, save_prompt_inner,
uninstall_managed_agents_block, BuiltinPromptStatus, SavedPrompt,
remember_current_instruction_prompt, resolve_instruction_path,
save_builtin_prompt_override_inner, save_prompt_inner, uninstall_managed_agents_block,
BuiltinPromptDetail, BuiltinPromptStatus, SavedPrompt,
};
#[cfg(test)]
use prompts::{
@@ -73,10 +74,12 @@ use providers::{
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
};
use providers::{
delete_provider_inner, fetch_provider_models_inner, import_ccswitch_codex_providers_inner,
list_saved_providers_inner, read_ccswitch_official_auth_inner, save_official_config_inner,
save_provider_inner, save_provider_toml_config_inner, switch_official_provider_inner,
switch_provider_inner, test_provider_connection_inner, ImportResult, OfficialAuthCandidate,
capture_live_chatgpt_config, delete_provider_inner, fetch_provider_models_inner,
get_official_config_draft_inner, import_ccswitch_codex_providers_inner,
list_saved_providers_inner, read_ccswitch_official_auth_inner, reset_official_provider_inner,
restore_official_provider_inner, save_official_config_inner, save_provider_inner,
save_provider_toml_config_inner, switch_official_provider_inner, switch_provider_inner,
test_provider_connection_inner, ImportResult, OfficialAuthCandidate, OfficialConfigDraft,
OfficialConfigInput, ProviderConnectionResult, ProviderInput, ProviderModelsResult,
ProviderTomlInput, SavedProvider,
};
@@ -653,6 +656,33 @@ async fn get_builtin_prompt_status() -> Result<Vec<BuiltinPromptStatus>> {
.map_err(|e| CodexxError::Config(format!("读取内置提示词状态失败: {e}")))?
}
#[tauri::command]
async fn get_builtin_prompt_detail(template_id: String) -> Result<BuiltinPromptDetail> {
tauri::async_runtime::spawn_blocking(move || builtin_prompt_detail_inner(&template_id))
.await
.map_err(|e| CodexxError::Config(format!("读取内置提示词失败: {e}")))?
}
#[tauri::command]
async fn save_builtin_prompt_override(
template_id: String,
content: String,
) -> Result<BuiltinPromptDetail> {
tauri::async_runtime::spawn_blocking(move || {
let id = template_id.trim();
// Resolve first so stale or unknown IDs cannot create detached local records.
let current = builtin_prompt_detail_inner(id)?;
let content = content.trim();
if !current.customized && current.content.trim() == content {
return Ok(current);
}
save_builtin_prompt_override_inner(id, content)?;
builtin_prompt_detail_inner(id)
})
.await
.map_err(|e| CodexxError::Config(format!("保存内置提示词修改失败: {e}")))?
}
#[tauri::command]
async fn refresh_builtin_prompts(config_dir: Option<String>) -> Result<Vec<BuiltinPromptStatus>> {
tauri::async_runtime::spawn_blocking(move || refresh_builtin_prompts_inner(config_dir))
@@ -857,6 +887,9 @@ async fn delete_saved_provider(id: String) -> Result<()> {
async fn get_codex_state(config_dir: Option<String>) -> Result<CodexState> {
tauri::async_runtime::spawn_blocking(move || {
let codex_dir = resolve_codex_dir(config_dir)?;
// Read-only refreshes only capture high-confidence ChatGPT credentials.
// Explicit provider switches also preserve official API-key configs.
let _ = capture_live_chatgpt_config(&codex_dir);
build_state(codex_dir)
})
.await
@@ -870,6 +903,31 @@ async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionRe
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
}
#[tauri::command]
async fn get_official_config_draft(
config_dir: Option<String>,
) -> Result<Option<OfficialConfigDraft>> {
tauri::async_runtime::spawn_blocking(move || get_official_config_draft_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
}
#[tauri::command]
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || restore_official_provider_inner(config_dir))
.await
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
}
#[tauri::command]
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
reset_official_provider_inner(input.config_dir, input.model)
})
.await
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
}
#[tauri::command]
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
@@ -1186,6 +1244,8 @@ pub fn run() {
import_ccswitch_codex_providers,
list_saved_prompts,
get_builtin_prompt_status,
get_builtin_prompt_detail,
save_builtin_prompt_override,
refresh_builtin_prompts,
remember_current_instruction,
save_prompt,
@@ -1196,6 +1256,9 @@ pub fn run() {
delete_saved_provider,
get_codex_state,
switch_official_provider,
get_official_config_draft,
restore_official_provider,
reset_official_provider,
save_official_config,
enable_instruction,
enable_instruction_template,
+278 -37
View File
@@ -1,6 +1,9 @@
use super::store::normalize_prompt_filename;
use super::store::{
builtin_prompt_override_ids_inner, builtin_prompt_override_inner, normalize_prompt_filename,
};
use super::types::{
BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt, GithubContentEntry,
BuiltinPromptDetail, BuiltinPromptStatus, BundledPromptMeta, CachedBuiltinPrompt,
GithubContentEntry,
};
use crate::constants::{
GITHUB_EXAMPLES_API, GITHUB_EXAMPLES_BASE, INSTRUCTION_54_CONTENT, INSTRUCTION_54_FILENAME,
@@ -140,6 +143,42 @@ pub(crate) fn stable_remote_prompt_id(filename: &str) -> String {
}
fn prompt_display_meta(filename: &str) -> (String, String, String) {
let curated = match filename.to_ascii_lowercase().as_str() {
"software-development-maintainer.md" => Some((
"长期维护工程师",
"正式项目的最小改动、复用、测试与安全规范",
"软件开发",
)),
"software-development-debugging.md" => Some((
"系统化调试与根因修复",
"从稳定复现、证据采集到最小修复与回归验证",
"软件开发",
)),
"software-development-code-review.md" => Some((
"严格代码审查",
"按严重级别发现缺陷、回归、安全风险与测试缺口",
"软件开发",
)),
"writing-clarity-editor.md" => Some((
"清晰表达与润色",
"保留原意与事实,优化中文或英文的结构、语气和可读性",
"写作辅助",
)),
"writing-technical-docs.md" => Some((
"技术文档写作",
"基于代码和事实编写 README、指南、API 与变更文档",
"写作辅助",
)),
"writing-structured-draft.md" => Some((
"结构化长文起草",
"把零散材料整理为提纲清晰、论证连贯的完整初稿",
"写作辅助",
)),
_ => None,
};
if let Some((title, subtitle, badge)) = curated {
return (title.to_string(), subtitle.to_string(), badge.to_string());
}
if let Some(meta) = bundled_prompt_metas()
.into_iter()
.find(|item| item.filename.eq_ignore_ascii_case(filename))
@@ -455,6 +494,22 @@ fn fetch_github_prompt_catalog() -> Result<RemotePromptCatalog> {
fetch_first_valid(&sources, parse_prompt_catalog)
}
fn confirm_prompt_catalog_with<Fetch>(
catalog: RemotePromptCatalog,
fetch_authoritative: Fetch,
) -> (RemotePromptCatalog, bool)
where
Fetch: FnOnce() -> Result<RemotePromptCatalog>,
{
if catalog.authoritative {
return (catalog, false);
}
match fetch_authoritative() {
Ok(authoritative) => (authoritative, false),
Err(_) => (catalog, true),
}
}
fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> BuiltinPromptStatus {
let (title, subtitle, badge) = prompt_display_meta(&cache.filename);
BuiltinPromptStatus {
@@ -470,6 +525,7 @@ fn prompt_status_from_cache(cache: CachedBuiltinPrompt, message: &str) -> Builti
sync_issue: None,
checked_at: Some(cache.checked_at),
message: message.to_string(),
customized: false,
}
}
@@ -526,6 +582,7 @@ fn refresh_builtin_prompt_from_source(
"已是最新在线模板"
}
.to_string(),
customized: false,
})
}
Err(_) => {
@@ -557,6 +614,7 @@ fn refresh_builtin_prompt_from_source(
"在线模板暂时不可用,且没有本地副本"
}
.to_string(),
customized: false,
})
}
}
@@ -576,9 +634,54 @@ fn bundled_prompt_status(meta: BundledPromptMeta, message: &str) -> BuiltinPromp
sync_issue: None,
checked_at: None,
message: message.to_string(),
customized: false,
}
}
fn customized_prompt_status(id: &str, filename: &str) -> Result<BuiltinPromptStatus> {
let mut status = if let Some(cache) = cached_builtin_prompt(id)? {
prompt_status_from_cache(cache, "本地已修改,GitHub 同步已跳过")
} else if let Some(meta) = bundled_prompt_meta(id) {
bundled_prompt_status(meta, "本地已修改,GitHub 同步已跳过")
} else {
let (title, subtitle, badge) = prompt_display_meta(filename);
BuiltinPromptStatus {
id: id.to_string(),
filename: filename.to_string(),
title,
subtitle,
badge,
source_url: builtin_prompt_source_url(filename),
cached: false,
updated: false,
content_source: "local".to_string(),
sync_issue: None,
checked_at: None,
message: "本地已修改,GitHub 同步已跳过".to_string(),
customized: true,
}
};
status.customized = true;
status.message = "本地已修改,GitHub 同步已跳过".to_string();
Ok(status)
}
fn mark_customized_prompt_statuses(
statuses: &mut [BuiltinPromptStatus],
customized_ids: &HashSet<String>,
) {
for status in statuses {
if customized_ids.contains(&status.id) {
status.customized = true;
status.message = "本地已修改,GitHub 同步已跳过".to_string();
}
}
}
fn should_sync_prompt_content(id: &str, customized_ids: &HashSet<String>) -> bool {
!customized_ids.contains(id)
}
pub(crate) fn cached_prompt_fallback_statuses(
caches: Vec<CachedBuiltinPrompt>,
) -> Vec<BuiltinPromptStatus> {
@@ -621,7 +724,10 @@ pub(crate) fn cached_prompt_fallback_statuses(
}
pub(crate) fn builtin_prompt_status_inner() -> Result<Vec<BuiltinPromptStatus>> {
Ok(cached_prompt_fallback_statuses(cached_builtin_prompts()?))
let customized_ids = builtin_prompt_override_ids_inner()?;
let mut statuses = cached_prompt_fallback_statuses(cached_builtin_prompts()?);
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
Ok(statuses)
}
pub(crate) fn refresh_builtin_prompts_with_active(
@@ -630,38 +736,26 @@ pub(crate) fn refresh_builtin_prompts_with_active(
let _cache_guard = BUILTIN_PROMPT_CACHE_LOCK
.lock()
.map_err(|_| CodexxError::Database("提示词缓存锁已损坏".to_string()))?;
let mut catalog = match fetch_prompt_catalog() {
let customized_ids = builtin_prompt_override_ids_inner()?;
let catalog = match fetch_prompt_catalog() {
Ok(catalog) => catalog,
Err(_) => {
let mut statuses = builtin_prompt_status_inner()?;
for status in &mut statuses {
status.sync_issue = Some("catalog".to_string());
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
if !status.customized {
status.message = "在线模板目录暂时不可用,已保留本地内容".to_string();
}
}
return Ok(statuses);
}
};
let cached_before = cached_builtin_prompts()?;
let mut catalog_confirmation_failed = false;
if !catalog.authoritative {
let cdn_ids = catalog
.prompts
.iter()
.map(|(id, _)| id.clone())
.collect::<HashSet<_>>();
let cache_may_be_newer = cached_before
.iter()
.any(|cache| !cdn_ids.contains(&cache.id));
let bundled_missing = bundled_prompt_metas()
.into_iter()
.any(|meta| !cdn_ids.contains(meta.id));
if cache_may_be_newer || bundled_missing {
match fetch_github_prompt_catalog() {
Ok(authoritative) => catalog = authoritative,
Err(_) => catalog_confirmation_failed = true,
}
}
}
// A successful CDN response can still contain an old @main directory. Always
// confirm directory membership with GitHub so newly added and removed files
// become visible immediately; keep the CDN result as the offline fallback.
let (catalog, catalog_confirmation_failed) =
confirm_prompt_catalog_with(catalog, fetch_github_prompt_catalog);
let remote_ids = catalog
.prompts
.iter()
@@ -674,29 +768,46 @@ pub(crate) fn refresh_builtin_prompts_with_active(
.collect::<HashSet<_>>();
let mut statuses = Vec::new();
for (id, filename) in catalog.prompts {
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
if !should_sync_prompt_content(&id, &customized_ids) {
statuses.push(customized_prompt_status(&id, &filename)?);
} else {
let bundled = bundled_prompt_meta(&id).map(|meta| meta.content);
statuses.push(refresh_builtin_prompt_from_source(&id, &filename, bundled)?);
}
}
for meta in bundled_prompt_metas() {
if !remote_ids.contains(meta.id) {
statuses.push(bundled_prompt_status(
meta,
"在线目录暂未提供该模板,使用软件内置版本",
));
statuses.push(if customized_ids.contains(meta.id) {
customized_prompt_status(meta.id, meta.filename)?
} else {
bundled_prompt_status(meta, "在线目录暂未提供该模板,使用软件内置版本")
});
}
}
if catalog.authoritative {
let mut retained_ids = remote_ids.clone();
for customized_id in &customized_ids {
retained_ids.insert(customized_id.clone());
if !remote_ids.contains(customized_id)
&& !statuses.iter().any(|status| status.id == *customized_id)
{
if let Some(cache) = cached_builtin_prompt(customized_id)? {
statuses.push(customized_prompt_status(customized_id, &cache.filename)?);
}
}
}
if let Some(active_id) = active_remote_builtin_prompt_id() {
if !remote_ids.contains(&active_id) {
if let Some(cache) = cached_builtin_prompt(&active_id)? {
let mut status = prompt_status_from_cache(
cache,
"该在线模板已下架,当前配置继续使用本地副本",
);
status.content_source = "removed".to_string();
statuses.push(status);
if !statuses.iter().any(|status| status.id == active_id) {
let mut status = prompt_status_from_cache(
cache,
"该在线模板已下架,当前配置继续使用本地副本",
);
status.content_source = "removed".to_string();
statuses.push(status);
}
}
retained_ids.insert(active_id);
}
@@ -717,6 +828,7 @@ pub(crate) fn refresh_builtin_prompts_with_active(
if catalog_confirmation_failed {
mark_catalog_confirmation_failed(&mut statuses);
}
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
let order = bundled_prompt_metas()
.into_iter()
.enumerate()
@@ -752,6 +864,14 @@ pub(crate) fn builtin_prompt_content(
.map(|item| item.filename.clone())
.or_else(|| bundled.map(|item| item.filename.to_string()))
.ok_or_else(|| CodexxError::Config(format!("提示词模板不存在或尚未同步: {id}")))?;
if let Some(content) = builtin_prompt_override_inner(id)? {
return Ok((
filename.clone(),
format!("./{filename}"),
content,
"本地已修改".to_string(),
));
}
let trust = PromptContentTrust {
cached: cached.as_ref().map(|item| item.content.as_str()),
bundled: bundled.map(|item| item.content),
@@ -784,6 +904,23 @@ pub(crate) fn builtin_prompt_content(
))
}
pub(crate) fn builtin_prompt_detail_inner(template_id: &str) -> Result<BuiltinPromptDetail> {
let id = if template_id.trim().is_empty() {
"gpt5.5-unrestricted"
} else {
template_id.trim()
};
let (filename, _, content, _) = builtin_prompt_content(id)?;
let (title, _, _) = prompt_display_meta(&filename);
Ok(BuiltinPromptDetail {
id: id.to_string(),
filename,
title,
content,
customized: builtin_prompt_override_inner(id)?.is_some(),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -853,6 +990,110 @@ mod tests {
std::fs::remove_file(database_path).expect("remove prompt cache database");
}
#[test]
fn curated_remote_prompts_have_user_facing_metadata() {
assert_eq!(
prompt_display_meta("software-development-maintainer.md"),
(
"长期维护工程师".to_string(),
"正式项目的最小改动、复用、测试与安全规范".to_string(),
"软件开发".to_string(),
)
);
assert_eq!(
prompt_display_meta("writing-technical-docs.md").2,
"写作辅助"
);
}
#[test]
fn curated_remote_prompt_ids_retain_category_prefixes() {
assert!(stable_remote_prompt_id("software-development-debugging.md")
.starts_with("github-software-development-debugging-"));
assert!(stable_remote_prompt_id("writing-clarity-editor.md")
.starts_with("github-writing-clarity-editor-"));
}
#[test]
fn locally_modified_prompt_is_excluded_from_content_sync() {
let customized_ids = HashSet::from(["edited-template".to_string()]);
assert!(!should_sync_prompt_content(
"edited-template",
&customized_ids
));
assert!(should_sync_prompt_content(
"new-github-template",
&customized_ids
));
}
#[test]
fn locally_modified_prompt_status_is_visible_after_restart() {
let cache = CachedBuiltinPrompt {
id: "edited-template".to_string(),
filename: "edited-template.md".to_string(),
source_url: "https://example.test/edited-template.md".to_string(),
content: "upstream cache".to_string(),
checked_at: "2026-07-28T00:00:00Z".to_string(),
};
let mut statuses = cached_prompt_fallback_statuses(vec![cache]);
let customized_ids = HashSet::from(["edited-template".to_string()]);
mark_customized_prompt_statuses(&mut statuses, &customized_ids);
let status = statuses
.iter()
.find(|status| status.id == "edited-template")
.expect("customized cached prompt remains visible");
assert!(status.customized);
assert!(status.message.contains("同步已跳过"));
}
#[test]
fn stale_cdn_catalog_is_always_replaced_by_github_catalog() {
let cdn = RemotePromptCatalog {
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
authoritative: false,
};
let github = RemotePromptCatalog {
prompts: vec![
("bundled".to_string(), "bundled.md".to_string()),
(
"github-software-development-debugging-test".to_string(),
"software-development-debugging.md".to_string(),
),
],
authoritative: true,
};
let mut calls = 0;
let (confirmed, failed) = confirm_prompt_catalog_with(cdn, || {
calls += 1;
Ok(github)
});
assert_eq!(calls, 1);
assert!(!failed);
assert!(confirmed.authoritative);
assert_eq!(confirmed.prompts.len(), 2);
}
#[test]
fn unavailable_github_catalog_keeps_cdn_fallback_for_retry() {
let cdn = RemotePromptCatalog {
prompts: vec![("bundled".to_string(), "bundled.md".to_string())],
authoritative: false,
};
let (fallback, failed) =
confirm_prompt_catalog_with(cdn, || Err(CodexxError::Config("offline".to_string())));
assert!(failed);
assert!(!fallback.authoritative);
assert_eq!(fallback.prompts.len(), 1);
}
#[test]
fn syncing_remote_prompt_updates_the_existing_sqlite_row() {
let conn = prompt_cache_connection();
+4 -4
View File
@@ -4,8 +4,8 @@ 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,
builtin_prompt_content, builtin_prompt_detail_inner, 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,
@@ -13,9 +13,9 @@ pub(crate) use managed_agents::{
};
pub(crate) use store::{
delete_prompt_inner, get_saved_prompt_inner, list_saved_prompts_inner,
normalize_prompt_filename, save_prompt_inner,
normalize_prompt_filename, save_builtin_prompt_override_inner, save_prompt_inner,
};
pub(crate) use types::{BuiltinPromptStatus, SavedPrompt};
pub(crate) use types::{BuiltinPromptDetail, BuiltinPromptStatus, SavedPrompt};
#[cfg(test)]
pub(crate) use catalog::{
+143
View File
@@ -2,6 +2,7 @@ use super::types::SavedPrompt;
use crate::error::{CodexxError, Result};
use crate::{now_rfc3339, open_db};
use rusqlite::params;
use std::collections::HashSet;
pub(crate) fn normalize_prompt_filename(input: &str, fallback: &str) -> String {
let raw = input.trim().trim_end_matches(".md");
@@ -107,6 +108,68 @@ pub(crate) fn delete_prompt_inner(id: &str) -> Result<()> {
Ok(())
}
fn builtin_prompt_override_from_connection(
conn: &rusqlite::Connection,
template_id: &str,
) -> Result<Option<String>> {
match conn.query_row(
"SELECT content FROM builtin_prompt_overrides WHERE template_id = ?1",
[template_id],
|row| row.get(0),
) {
Ok(content) => Ok(Some(content)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(CodexxError::Database(e.to_string())),
}
}
pub(crate) fn builtin_prompt_override_inner(template_id: &str) -> Result<Option<String>> {
let conn = open_db()?;
builtin_prompt_override_from_connection(&conn, template_id.trim())
}
pub(crate) fn builtin_prompt_override_ids_inner() -> Result<HashSet<String>> {
let conn = open_db()?;
let mut stmt = conn
.prepare("SELECT template_id FROM builtin_prompt_overrides")
.map_err(|e| CodexxError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| CodexxError::Database(e.to_string()))?;
rows.map(|row| row.map_err(|e| CodexxError::Database(e.to_string())))
.collect()
}
fn save_builtin_prompt_override_on_connection(
conn: &rusqlite::Connection,
template_id: &str,
content: &str,
) -> Result<()> {
let id = template_id.trim();
if id.is_empty() {
return Err(CodexxError::Config("提示词模板标识不能为空".to_string()));
}
if content.trim().is_empty() {
return Err(CodexxError::Config("提示词内容不能为空".to_string()));
}
let now = now_rfc3339();
conn.execute(
"INSERT INTO builtin_prompt_overrides (template_id, content, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(template_id) DO UPDATE SET
content = excluded.content,
updated_at = excluded.updated_at",
params![id, content, now],
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
Ok(())
}
pub(crate) fn save_builtin_prompt_override_inner(template_id: &str, content: &str) -> Result<()> {
let conn = open_db()?;
save_builtin_prompt_override_on_connection(&conn, template_id, content)
}
fn find_saved_prompt_by_content(content: &str) -> Result<Option<SavedPrompt>> {
let conn = open_db()?;
let mut stmt = conn
@@ -167,3 +230,83 @@ pub(super) fn find_saved_prompt_by_current_file(
Err(e) => Err(CodexxError::Database(e.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn override_connection() -> Connection {
let conn = Connection::open_in_memory().expect("open override database");
conn.execute_batch(
"CREATE TABLE builtin_prompt_overrides (
template_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.expect("create override table");
conn
}
#[test]
fn builtin_prompt_override_can_be_edited_repeatedly() {
let conn = override_connection();
save_builtin_prompt_override_on_connection(&conn, "template", "first")
.expect("save initial override");
save_builtin_prompt_override_on_connection(&conn, "template", "second")
.expect("update override");
assert_eq!(
builtin_prompt_override_from_connection(&conn, "template")
.expect("read override")
.as_deref(),
Some("second")
);
}
#[test]
fn builtin_prompt_override_rejects_empty_content() {
let conn = override_connection();
let error = save_builtin_prompt_override_on_connection(&conn, "template", " \n")
.expect_err("reject empty override");
assert!(error.to_string().contains("内容不能为空"));
}
#[test]
fn builtin_prompt_override_survives_database_reopen() {
let database_path = std::env::temp_dir().join(format!(
"codexx-prompt-override-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_nanos()
));
{
let conn = Connection::open(&database_path).expect("open override database");
conn.execute_batch(
"CREATE TABLE builtin_prompt_overrides (
template_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.expect("create override table");
save_builtin_prompt_override_on_connection(&conn, "template", "local content")
.expect("save override");
}
let reopened = Connection::open(&database_path).expect("reopen override database");
assert_eq!(
builtin_prompt_override_from_connection(&reopened, "template")
.expect("read persisted override")
.as_deref(),
Some("local content")
);
drop(reopened);
std::fs::remove_file(database_path).expect("remove override database");
}
}
@@ -24,6 +24,17 @@ pub(crate) struct BuiltinPromptStatus {
pub(crate) sync_issue: Option<String>,
pub(crate) checked_at: Option<String>,
pub(crate) message: String,
pub(crate) customized: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BuiltinPromptDetail {
pub(crate) id: String,
pub(crate) filename: String,
pub(crate) title: String,
pub(crate) content: String,
pub(crate) customized: bool,
}
#[derive(Debug, Clone, Copy)]
+127 -37
View File
@@ -1,4 +1,8 @@
use super::ccswitch::codex_section_from_table;
use super::official_auth::{
auth_value_has_material, capture_live_official_config, live_config_is_official,
mark_official_config_reset, official_config_candidate, save_official_config_snapshot,
};
use super::{
custom_provider_id, experimental_bearer_token_from_doc, reserved_codex_provider_id,
save_detected_provider_inner, SavedProvider,
@@ -48,6 +52,12 @@ pub(crate) struct OfficialConfigInput {
pub(crate) auth_json: Option<String>,
}
enum OfficialAuthAction {
Keep,
Replace(Value),
Remove,
}
fn live_auth_api_key(codex_dir: &Path) -> Result<Option<String>> {
let path = auth_path(codex_dir);
if !path.exists() {
@@ -168,7 +178,7 @@ fn set_provider_bearer_token(doc: &mut DocumentMut, token: &str) {
fn apply_official_config(
config_dir: Option<String>,
model: Option<String>,
auth_json: Option<String>,
auth_action: OfficialAuthAction,
action: &str,
message: &str,
) -> Result<ActionResult> {
@@ -206,17 +216,13 @@ fn apply_official_config(
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(),
));
match auth_action {
OfficialAuthAction::Keep => {}
OfficialAuthAction::Replace(value) => write_json(&auth, &value)?,
OfficialAuthAction::Remove if auth.exists() => {
fs::remove_file(&auth).map_err(|error| io_err(&auth, error))?;
}
write_json(&auth, &parsed)?;
OfficialAuthAction::Remove => {}
}
let state = build_state(codex_dir)?;
@@ -237,16 +243,35 @@ where
{
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(
let candidate = official_config_candidate(&codex_dir, false)?;
if candidate.is_none() && live_auth_has_material(&codex_dir)? {
return Err(CodexxError::Config(
"当前 auth.json 来自中转模式,且没有可信的官方认证快照。请使用“还原官方配置”,或使用“新建官方配置”后重新登录".to_string(),
));
}
let model = candidate
.as_ref()
.and_then(|candidate| candidate.model.clone());
let auth_action = candidate
.as_ref()
.map(|candidate| OfficialAuthAction::Replace(candidate.auth.clone()))
.unwrap_or(OfficialAuthAction::Keep);
let source = candidate.as_ref().map(|candidate| candidate.source.clone());
let message = source
.as_deref()
.map(|source| format!("已切换到 OpenAI Official,并恢复:{source}"))
.unwrap_or_else(|| "已切换到 OpenAI Official,请在 Codex 中完成登录".to_string());
let result = apply_official_config(
config_dir,
None,
None,
model.clone(),
auth_action,
"switch-official",
"已切换到 OpenAI Official(auth.json 保持当前 live 状态)",
)
&message,
)?;
if let Some(candidate) = candidate {
save_official_config_snapshot(&codex_dir, model, &candidate.auth)?;
}
Ok(result)
}
pub(crate) fn switch_official_provider_inner(config_dir: Option<String>) -> Result<ActionResult> {
@@ -258,44 +283,107 @@ pub(crate) fn save_official_config_inner(
model: Option<String>,
auth_json: Option<String>,
) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
let codex_dir = resolve_codex_dir(config_dir.clone())?;
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
let 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
.filter(|s| !s.is_empty());
let parsed_auth = 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() {
if !parsed.is_object() || !auth_value_has_material(&parsed) {
return Err(CodexxError::Config(
"auth.json 必须是 JSON object".to_string(),
"官方 auth.json 必须是包含有效认证信息的 JSON object".to_string(),
));
}
write_json(&auth, &parsed)?;
parsed
} else {
official_config_candidate(&codex_dir, true)?
.map(|candidate| candidate.auth)
.ok_or_else(|| {
CodexxError::Config("没有可保存的官方认证,请先完成官方登录".to_string())
})?
};
if live_config_is_official(&codex_dir)? {
let result = apply_official_config(
config_dir,
model.clone(),
OfficialAuthAction::Replace(parsed_auth.clone()),
"save-official",
"已保存并更新当前 OpenAI Official 配置",
)?;
save_official_config_snapshot(&codex_dir, model, &parsed_auth)?;
return Ok(result);
}
let backup_id = create_backup(&codex_dir, "save-official-snapshot")?;
save_official_config_snapshot(&codex_dir, model, &parsed_auth)?;
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message: "已保存 OpenAI Official 配置(未切换启用)".to_string(),
message: "已保存 OpenAI Official 独立配置,当前中转配置未被改动".to_string(),
backup_id,
state,
})
}
fn live_auth_has_material(codex_dir: &Path) -> Result<bool> {
let path = auth_path(codex_dir);
if !path.is_file() {
return Ok(false);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let value: Value = serde_json::from_str(&text).map_err(|error| json_err(&path, error))?;
Ok(auth_value_has_material(&value))
}
pub(crate) fn restore_official_provider_inner(config_dir: Option<String>) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
let candidate = official_config_candidate(&codex_dir, true)?.ok_or_else(|| {
CodexxError::Config(
"未找到可信的官方认证快照或官方模式历史备份,请新建官方配置后重新登录".to_string(),
)
})?;
let model = candidate.model.clone();
let message = format!(
"已还原 OpenAI Official 独立配置,当前供应商未切换。认证来源:{}",
candidate.source
);
save_official_config_snapshot(&codex_dir, model, &candidate.auth)?;
let state = build_state(codex_dir)?;
Ok(ActionResult {
ok: true,
message,
backup_id: None,
state,
})
}
pub(crate) fn reset_official_provider_inner(
config_dir: Option<String>,
model: Option<String>,
) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
persist_detected_live_custom_provider(&codex_dir)?;
let model = model
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let result = apply_official_config(
config_dir,
model.clone(),
OfficialAuthAction::Remove,
"reset-official",
"已新建 OpenAI Official 配置,请在 Codex 中重新登录",
)?;
mark_official_config_reset(&codex_dir, model)?;
Ok(result)
}
pub(crate) fn save_provider_toml_config_with_pre_persist<F>(
input: ProviderTomlInput,
pre_persist: F,
@@ -305,6 +393,7 @@ where
{
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
ensure_directory(&codex_dir)?;
capture_live_official_config(&codex_dir)?;
pre_persist(&codex_dir)?;
let cfg = config_path(&codex_dir);
let backup_id = create_backup(&codex_dir, "save-provider-toml")?;
@@ -347,6 +436,7 @@ where
{
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
ensure_directory(&codex_dir)?;
capture_live_official_config(&codex_dir)?;
pre_persist(&codex_dir)?;
let cfg = config_path(&codex_dir);
let backup_id = create_backup(&codex_dir, "switch-provider")?;
+10 -2
View File
@@ -1,6 +1,7 @@
mod ccswitch;
mod connection;
mod live;
mod official_auth;
mod store;
use crate::error::Result;
@@ -27,8 +28,15 @@ pub(crate) use live::{
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,
reset_official_provider_inner, restore_official_provider_inner, save_official_config_inner,
save_provider_toml_config_inner, switch_official_provider_inner, switch_provider_inner,
OfficialConfigInput, ProviderInput, ProviderTomlInput,
};
#[cfg(test)]
pub(crate) use official_auth::official_snapshot_path_for_test;
pub(crate) use official_auth::{
capture_live_chatgpt_config, get_official_config_draft_inner, official_auth_available,
OfficialConfigDraft,
};
#[cfg(test)]
pub(crate) use store::{
@@ -0,0 +1,372 @@
use crate::backups::{action_backup_root, BackupMeta};
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err, json_err, parse_toml_document, write_json};
use crate::paths::app_home;
use crate::{auth_path, config_path, string_value};
use chrono::Local;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
const SNAPSHOT_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub(crate) struct OfficialConfigCandidate {
pub(crate) auth: Value,
pub(crate) model: Option<String>,
pub(crate) source: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialConfigDraft {
auth_json: String,
model: Option<String>,
source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OfficialConfigSnapshot {
version: u32,
codex_dir: String,
captured_at: String,
model: Option<String>,
#[serde(default)]
auth: Option<Value>,
}
enum SnapshotState {
Missing,
Reset,
Ready(OfficialConfigCandidate),
}
fn canonical_identity(path: &Path) -> String {
fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
fn snapshot_path(codex_dir: &Path) -> Result<PathBuf> {
let identity = canonical_identity(codex_dir);
let digest = Sha256::digest(identity.as_bytes());
Ok(app_home()?
.join("official-configs")
.join(format!("{digest:x}.json")))
}
fn value_has_material(value: &Value) -> bool {
match value {
Value::Null => false,
Value::String(value) => !value.trim().is_empty(),
Value::Array(values) => values.iter().any(value_has_material),
Value::Object(values) => values.values().any(value_has_material),
Value::Bool(_) | Value::Number(_) => true,
}
}
pub(crate) fn auth_value_has_material(value: &Value) -> bool {
value.as_object().is_some_and(|auth| {
auth.iter()
.filter(|(key, _)| key.as_str() != "auth_mode")
.any(|(_, value)| value_has_material(value))
})
}
fn is_chatgpt_auth(value: &Value) -> bool {
let chatgpt_mode = value
.get("auth_mode")
.and_then(Value::as_str)
.is_some_and(|mode| mode.eq_ignore_ascii_case("chatgpt"));
let has_api_key = value
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.is_some_and(|key| !key.trim().is_empty());
chatgpt_mode
&& !has_api_key
&& value
.get("tokens")
.and_then(Value::as_object)
.is_some_and(|tokens| {
["access_token", "refresh_token", "id_token"]
.iter()
.any(|key| {
tokens
.get(*key)
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
})
})
}
fn read_auth_value(path: &Path) -> Result<Option<Value>> {
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
let value: Value = serde_json::from_str(&text).map_err(|error| json_err(path, error))?;
if !value.is_object() || !auth_value_has_material(&value) {
return Ok(None);
}
Ok(Some(value))
}
fn official_model(codex_dir: &Path) -> Result<Option<String>> {
let path = config_path(codex_dir);
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let doc = parse_toml_document(&path, &text)?;
Ok(string_value(&doc, "model"))
}
pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
let path = config_path(codex_dir);
if !path.is_file() {
return Ok(true);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let doc = parse_toml_document(&path, &text)?;
Ok(string_value(&doc, "model_provider")
.as_deref()
.is_none_or(|provider| provider.eq_ignore_ascii_case("openai")))
}
fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>) -> Result<()> {
let path = snapshot_path(codex_dir)?;
if let Some(parent) = path.parent() {
ensure_directory(parent)?;
}
let snapshot = OfficialConfigSnapshot {
version: SNAPSHOT_VERSION,
codex_dir: canonical_identity(codex_dir),
captured_at: Local::now().to_rfc3339(),
model,
auth,
};
let value = serde_json::to_value(snapshot)
.map_err(|error| CodexxError::Config(format!("序列化官方配置快照失败: {error}")))?;
write_json(&path, &value)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.map_err(|error| io_err(&path, error))?;
}
Ok(())
}
pub(crate) fn save_official_config_snapshot(
codex_dir: &Path,
model: Option<String>,
auth: &Value,
) -> Result<()> {
if !auth.is_object() || !auth_value_has_material(auth) {
return Err(CodexxError::Config(
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
));
}
write_snapshot(codex_dir, model, Some(auth.clone()))
}
pub(crate) fn mark_official_config_reset(codex_dir: &Path, model: Option<String>) -> Result<()> {
write_snapshot(codex_dir, model, None)
}
pub(crate) fn capture_live_official_config(codex_dir: &Path) -> Result<bool> {
if !live_config_is_official(codex_dir)? {
return Ok(false);
}
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
return Ok(false);
};
save_official_config_snapshot(codex_dir, official_model(codex_dir)?, &auth)?;
Ok(true)
}
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
if !live_config_is_official(codex_dir)? {
return Ok(false);
}
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
return Ok(false);
};
if !is_chatgpt_auth(&auth) {
return Ok(false);
}
save_official_config_snapshot(codex_dir, official_model(codex_dir)?, &auth)?;
Ok(true)
}
fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
let path = snapshot_path(codex_dir)?;
if !path.is_file() {
return Ok(SnapshotState::Missing);
}
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
let snapshot: OfficialConfigSnapshot =
serde_json::from_str(&text).map_err(|error| json_err(&path, error))?;
if snapshot.version != SNAPSHOT_VERSION || snapshot.codex_dir != canonical_identity(codex_dir) {
return Err(CodexxError::Config(format!(
"官方配置快照与当前 CODEX_HOME 不匹配: {}",
path.display()
)));
}
let Some(auth) = snapshot.auth else {
return Ok(SnapshotState::Reset);
};
if !auth.is_object() || !auth_value_has_material(&auth) {
return Err(CodexxError::Config(format!(
"官方配置快照不包含可用认证: {}",
path.display()
)));
}
Ok(SnapshotState::Ready(OfficialConfigCandidate {
auth,
model: snapshot.model,
source: "Codex-X 官方配置快照".to_string(),
}))
}
fn backup_config_is_official(dir: &Path, meta: &BackupMeta) -> bool {
if !meta.had_config {
return true;
}
let path = dir.join("config.toml");
let Ok(text) = fs::read_to_string(&path) else {
return false;
};
let Ok(doc) = parse_toml_document(&path, &text) else {
return false;
};
string_value(&doc, "model_provider")
.as_deref()
.is_none_or(|provider| provider.eq_ignore_ascii_case("openai"))
}
fn backup_model(dir: &Path, meta: &BackupMeta) -> Option<String> {
if !meta.had_config {
return None;
}
let path = dir.join("config.toml");
let text = fs::read_to_string(&path).ok()?;
let doc = parse_toml_document(&path, &text).ok()?;
string_value(&doc, "model")
}
fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
let root = action_backup_root(codex_dir)?;
if !root.is_dir() {
return Ok(None);
}
let identity = canonical_identity(codex_dir);
let mut candidates = Vec::new();
for entry in fs::read_dir(&root).map_err(|error| io_err(&root, error))? {
let entry = entry.map_err(|error| io_err(&root, error))?;
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let meta_path = dir.join("meta.json");
let Ok(meta_text) = fs::read_to_string(&meta_path) else {
continue;
};
let Ok(meta) = serde_json::from_str::<BackupMeta>(&meta_text) else {
continue;
};
if !meta.had_auth
|| canonical_identity(Path::new(&meta.codex_dir)) != identity
|| !backup_config_is_official(&dir, &meta)
{
continue;
}
let Ok(Some(auth)) = read_auth_value(&dir.join("auth.json")) else {
continue;
};
// Old Codex-X versions could mark config.toml as official while leaving
// a proxy API key in auth.json. Historical auto-recovery therefore only
// trusts unambiguous ChatGPT login backups. Official API keys remain
// supported through an explicit Codex-X snapshot/save.
if !is_chatgpt_auth(&auth) {
continue;
}
candidates.push((meta.created_at.clone(), dir, meta, auth));
}
candidates.sort_by(|left, right| right.0.cmp(&left.0));
Ok(candidates
.into_iter()
.next()
.map(|(_, dir, meta, auth)| OfficialConfigCandidate {
auth,
model: backup_model(&dir, &meta),
source: format!("Codex-X 历史备份 {}", meta.created_at),
}))
}
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
return Ok(None);
};
if !is_chatgpt_auth(&auth) {
return Ok(None);
}
Ok(Some(OfficialConfigCandidate {
auth,
model: official_model(codex_dir)?,
source: "当前 ChatGPT 官方登录".to_string(),
}))
}
pub(crate) fn official_config_candidate(
codex_dir: &Path,
include_history_after_reset: bool,
) -> Result<Option<OfficialConfigCandidate>> {
match load_snapshot(codex_dir)? {
SnapshotState::Ready(candidate) => return Ok(Some(candidate)),
SnapshotState::Reset if !include_history_after_reset => return Ok(None),
SnapshotState::Missing | SnapshotState::Reset => {}
}
if let Some(candidate) = live_chatgpt_candidate(codex_dir)? {
return Ok(Some(candidate));
}
latest_official_backup(codex_dir)
}
pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
match load_snapshot(codex_dir)? {
SnapshotState::Ready(_) => return Ok(true),
SnapshotState::Reset => return Ok(false),
SnapshotState::Missing => {}
}
if live_config_is_official(codex_dir)? && read_auth_value(&auth_path(codex_dir))?.is_some() {
return Ok(true);
}
Ok(latest_official_backup(codex_dir)?.is_some())
}
pub(crate) fn get_official_config_draft_inner(
config_dir: Option<String>,
) -> Result<Option<OfficialConfigDraft>> {
let codex_dir = crate::resolve_codex_dir(config_dir)?;
let Some(candidate) = official_config_candidate(&codex_dir, true)? else {
return Ok(None);
};
let auth_json = serde_json::to_string_pretty(&candidate.auth)
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?;
Ok(Some(OfficialConfigDraft {
auth_json,
model: candidate.model,
source: candidate.source,
}))
}
#[cfg(test)]
pub(crate) fn official_snapshot_path_for_test(codex_dir: &Path) -> Result<PathBuf> {
snapshot_path(codex_dir)
}
+1 -5
View File
@@ -17,9 +17,5 @@ pub(crate) fn retired_builtin_skin_ids() -> &'static [&'static str] {
}
pub(crate) fn builtin_skin_assets() -> &'static [BuiltinSkinAsset] {
&[BuiltinSkinAsset {
id: BUILTIN_SKIN_ID,
manifest: include_str!("../resources/skin-presets/shiina-mashiro-blossom/theme.json"),
image: include_bytes!("../resources/skin-presets/shiina-mashiro-blossom/background.png"),
}]
&[]
}
+6 -25
View File
@@ -246,32 +246,13 @@ fn imported_theme_metadata_can_be_edited_without_changing_theme_assets() {
}
#[test]
fn builtin_theme_only_updates_surface_opacity() {
ensure_builtin_themes().expect("install built-in theme");
let before = read_manifest(&themes_root().expect("theme root").join(BUILTIN_SKIN_ID))
.expect("read built-in theme");
update_skin_theme_settings_inner(
BUILTIN_SKIN_ID.to_string(),
"Changed".to_string(),
"Changed tagline".to_string(),
0.48,
)
.expect("update built-in opacity");
let updated = read_manifest(&themes_root().expect("theme root").join(BUILTIN_SKIN_ID))
.expect("read updated built-in theme");
assert_eq!(updated.name, before.name);
assert_eq!(updated.tagline, before.tagline);
assert_eq!(updated.surface_opacity, Some(0.48));
}
#[test]
fn builtin_catalog_contains_only_localized_shiina_theme() {
fn builtin_catalog_is_not_embedded_but_source_remains_valid() {
let assets = builtin_skin_assets();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0].id, BUILTIN_SKIN_ID);
let manifest: SkinThemeManifest =
serde_json::from_str(assets[0].manifest).expect("parse built-in Shiina manifest");
assert!(assets.is_empty());
let manifest: SkinThemeManifest = serde_json::from_str(include_str!(
"../resources/skin-presets/shiina-mashiro-blossom/theme.json"
))
.expect("parse retained Shiina theme source");
assert_eq!(manifest.id, BUILTIN_SKIN_ID);
assert_eq!(manifest.name, "椎名真白·樱花画室");
assert_eq!(manifest.quote, "安静创作,也会发光");
+2 -2
View File
@@ -5,7 +5,7 @@ use crate::file_io::{io_err, json_err, parse_toml_document, read_to_string_if_ex
use crate::prompts::{
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
};
use crate::providers::{list_saved_providers_inner, SavedProvider};
use crate::providers::{list_saved_providers_inner, official_auth_available, SavedProvider};
use crate::{auth_path, config_path, string_value};
use serde::Serialize;
use serde_json::Value;
@@ -205,7 +205,7 @@ pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
auth_path: auth.display().to_string(),
config_exists: cfg.exists(),
auth_exists: auth.exists(),
official_auth_available: auth_has_material(&auth)?,
official_auth_available: official_auth_available(&codex_dir)?,
model,
model_provider,
instruction_file,
+341 -4
View File
@@ -1251,6 +1251,20 @@ fn switch_provider_reserved_builtin_ids_still_write_live_custom() {
#[test]
fn switch_official_persists_detected_custom_before_overwrite() {
let codex_dir = temp_codex_dir("switch-official-persist-current");
write_text(
&config_path(&codex_dir),
"model_provider = \"openai\"\nmodel = \"official-model\"\n",
)
.expect("write original official config");
write_json(
&auth_path(&codex_dir),
&json!({
"auth_mode": "chatgpt",
"tokens": {"access_token": "official-access-token"}
}),
)
.expect("write original official auth");
create_backup(&codex_dir, "seed-official").expect("backup official config");
write_text(
&config_path(&codex_dir),
r#"model_provider = "custom"
@@ -1283,16 +1297,25 @@ requires_openai_auth = true
assert_eq!(provider_a.base_url, "https://a.example.com/v1");
assert_eq!(provider_a.api_key.as_deref(), Some("sk-a-auth"));
assert_eq!(result.state.model_provider.as_deref(), Some("openai"));
assert_eq!(result.state.model.as_deref(), Some("official-model"));
assert!(!result
.state
.config_text
.contains("https://a.example.com/v1"));
let restored_auth: Value = serde_json::from_str(
&fs::read_to_string(auth_path(&codex_dir)).expect("read restored auth"),
)
.expect("parse restored auth");
assert_eq!(
restored_auth["tokens"]["access_token"].as_str(),
Some("official-access-token")
);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn switch_official_preserves_live_auth_json() {
fn switch_official_rejects_untrusted_live_auth_without_snapshot() {
let codex_dir = temp_codex_dir("switch-official-preserve-auth");
write_text(
&config_path(&codex_dir),
@@ -1316,9 +1339,9 @@ requires_openai_auth = true
)
.expect("write auth");
let result =
switch_official_provider_inner(Some(codex_dir.display().to_string())).expect("switch");
assert_eq!(result.state.model_provider.as_deref(), Some("openai"));
let error = switch_official_provider_inner(Some(codex_dir.display().to_string()))
.expect_err("untrusted live auth must not become official auth");
assert!(error.to_string().contains("没有可信的官方认证快照"));
let auth_text = fs::read_to_string(auth_path(&codex_dir)).expect("read auth");
let auth: Value = serde_json::from_str(&auth_text).expect("parse auth");
@@ -1330,6 +1353,320 @@ requires_openai_auth = true
auth.get("auth_mode").and_then(Value::as_str),
Some("apikey")
);
let config_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
assert!(config_text.contains("model_provider = \"custom\""));
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn switch_official_does_not_trust_api_key_only_official_history() {
let codex_dir = temp_codex_dir("switch-official-reject-polluted-history");
write_text(
&config_path(&codex_dir),
"model_provider = \"openai\"\nmodel = \"gpt-5.5\"\n",
)
.expect("write apparently official config");
write_json(
&auth_path(&codex_dir),
&json!({"OPENAI_API_KEY": "sk-proxy-in-old-backup", "auth_mode": "apikey"}),
)
.expect("write polluted official auth");
create_backup(&codex_dir, "old-broken-switch-official").expect("backup polluted state");
write_text(
&config_path(&codex_dir),
r#"model_provider = "custom"
model = "proxy-model"
[model_providers.custom]
name = "Proxy"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = true
"#,
)
.expect("write custom config");
let error = switch_official_provider_inner(Some(codex_dir.display().to_string()))
.expect_err("polluted history must not be restored");
assert!(error.to_string().contains("没有可信的官方认证快照"));
assert!(fs::read_to_string(config_path(&codex_dir))
.expect("read unchanged config")
.contains("model_provider = \"custom\""));
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn switch_provider_snapshot_restores_auth_after_external_cc_overwrite() {
let codex_dir = temp_codex_dir("switch-provider-official-snapshot");
write_text(
&config_path(&codex_dir),
"model_provider = \"openai\"\nmodel = \"official-model\"\n",
)
.expect("write official config");
let official_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "official-access-token",
"refresh_token": "official-refresh-token"
}
});
write_json(&auth_path(&codex_dir), &official_auth).expect("write official auth");
switch_provider_inner(ProviderInput {
config_dir: Some(codex_dir.display().to_string()),
_provider_id: Some("proxy".to_string()),
provider_name: "Proxy".to_string(),
base_url: "https://proxy.example.com/v1".to_string(),
model: "proxy-model".to_string(),
api_key: Some("sk-proxy".to_string()),
wire_api: Some("responses".to_string()),
requires_openai_auth: Some(true),
})
.expect("switch to proxy");
assert!(providers::official_snapshot_path_for_test(&codex_dir)
.expect("snapshot path")
.is_file());
write_json(
&auth_path(&codex_dir),
&json!({"OPENAI_API_KEY": "sk-cc-overwrite", "auth_mode": "apikey"}),
)
.expect("simulate cc-switch auth overwrite");
let result = switch_official_provider_inner(Some(codex_dir.display().to_string()))
.expect("restore official snapshot");
assert_eq!(result.state.model_provider.as_deref(), Some("openai"));
assert_eq!(result.state.model.as_deref(), Some("official-model"));
let restored: Value = serde_json::from_str(
&fs::read_to_string(auth_path(&codex_dir)).expect("read restored auth"),
)
.expect("parse restored auth");
assert_eq!(restored, official_auth);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn save_official_config_while_custom_does_not_touch_live_files() {
let codex_dir = temp_codex_dir("save-official-while-custom");
let custom_config = r#"model_provider = "custom"
model = "proxy-model"
[model_providers.custom]
name = "Proxy"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
let proxy_auth = json!({"OPENAI_API_KEY": "sk-proxy", "auth_mode": "apikey"});
write_text(&config_path(&codex_dir), custom_config).expect("write custom config");
write_json(&auth_path(&codex_dir), &proxy_auth).expect("write proxy auth");
save_official_config_inner(
Some(codex_dir.display().to_string()),
Some("official-model".to_string()),
Some(
json!({
"auth_mode": "chatgpt",
"tokens": {"access_token": "official-access-token"}
})
.to_string(),
),
)
.expect("save independent official config");
assert_eq!(
fs::read_to_string(config_path(&codex_dir)).expect("read live config"),
custom_config
);
let live_auth: Value =
serde_json::from_str(&fs::read_to_string(auth_path(&codex_dir)).expect("read live auth"))
.expect("parse live auth");
assert_eq!(live_auth, proxy_auth);
let switched = switch_official_provider_inner(Some(codex_dir.display().to_string()))
.expect("switch to saved official config");
assert_eq!(switched.state.model.as_deref(), Some("official-model"));
assert_eq!(switched.state.model_provider.as_deref(), Some("openai"));
let restored: Value = serde_json::from_str(
&fs::read_to_string(auth_path(&codex_dir)).expect("read official auth"),
)
.expect("parse official auth");
assert_eq!(
restored["tokens"]["access_token"].as_str(),
Some("official-access-token")
);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn state_reports_saved_official_auth_without_live_auth_file() {
let codex_dir = temp_codex_dir("state-saved-official-auth");
write_text(
&config_path(&codex_dir),
r#"model_provider = "custom"
model = "proxy-model"
[model_providers.custom]
name = "Proxy"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = false
experimental_bearer_token = "sk-proxy"
"#,
)
.expect("write custom config");
let result = save_official_config_inner(
Some(codex_dir.display().to_string()),
Some("official-model".to_string()),
Some(
json!({
"auth_mode": "chatgpt",
"tokens": {"access_token": "official-access-token"}
})
.to_string(),
),
)
.expect("save independent official config");
assert!(!auth_path(&codex_dir).exists());
let state = serde_json::to_value(result.state).expect("serialize state");
assert_eq!(state["authExists"].as_bool(), Some(false));
assert_eq!(state["officialAuthAvailable"].as_bool(), Some(true));
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn restore_official_config_while_custom_does_not_switch_live_provider() {
let codex_dir = temp_codex_dir("restore-official-without-switching");
write_text(
&config_path(&codex_dir),
"model_provider = \"openai\"\nmodel = \"official-model\"\n",
)
.expect("write official config");
write_json(
&auth_path(&codex_dir),
&json!({
"auth_mode": "chatgpt",
"tokens": {"access_token": "official-access-token"}
}),
)
.expect("write official auth");
create_backup(&codex_dir, "seed-official").expect("backup official config");
let custom_config = r#"model_provider = "custom"
model = "proxy-model"
[model_providers.custom]
name = "Proxy"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
let proxy_auth = json!({"OPENAI_API_KEY": "sk-proxy", "auth_mode": "apikey"});
write_text(&config_path(&codex_dir), custom_config).expect("write custom config");
write_json(&auth_path(&codex_dir), &proxy_auth).expect("write proxy auth");
let result = restore_official_provider_inner(Some(codex_dir.display().to_string()))
.expect("restore independent official config");
assert_eq!(result.state.model_provider.as_deref(), Some("custom"));
assert_eq!(result.state.model.as_deref(), Some("proxy-model"));
assert!(result.backup_id.is_none());
assert_eq!(
fs::read_to_string(config_path(&codex_dir)).expect("read unchanged config"),
custom_config
);
let live_auth: Value =
serde_json::from_str(&fs::read_to_string(auth_path(&codex_dir)).expect("read live auth"))
.expect("parse live auth");
assert_eq!(live_auth, proxy_auth);
let snapshot_path = providers::official_snapshot_path_for_test(&codex_dir)
.expect("resolve official snapshot path");
let snapshot: Value = serde_json::from_str(
&fs::read_to_string(snapshot_path).expect("read restored official snapshot"),
)
.expect("parse official snapshot");
assert_eq!(snapshot["model"].as_str(), Some("official-model"));
assert_eq!(
snapshot["auth"]["tokens"]["access_token"].as_str(),
Some("official-access-token")
);
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn reset_official_config_clears_proxy_auth_and_requires_fresh_login() {
let codex_dir = temp_codex_dir("reset-official-config");
write_text(
&config_path(&codex_dir),
r#"model_provider = "custom"
model = "proxy-model"
[model_providers.custom]
name = "Proxy"
base_url = "https://proxy.example.com/v1"
wire_api = "responses"
requires_openai_auth = true
"#,
)
.expect("write custom config");
write_json(
&auth_path(&codex_dir),
&json!({"OPENAI_API_KEY": "sk-proxy", "auth_mode": "apikey"}),
)
.expect("write proxy auth");
let result = reset_official_provider_inner(
Some(codex_dir.display().to_string()),
Some("official-model".to_string()),
)
.expect("reset official config");
assert_eq!(result.state.model_provider.as_deref(), Some("openai"));
assert_eq!(result.state.model.as_deref(), Some("official-model"));
assert!(!auth_path(&codex_dir).exists());
assert!(!result.state.config_text.contains("model_providers.custom"));
assert!(providers::official_snapshot_path_for_test(&codex_dir)
.expect("snapshot path")
.is_file());
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn invalid_official_auth_does_not_partially_change_live_config() {
let codex_dir = temp_codex_dir("invalid-official-auth");
let original_config = "model_provider = \"openai\"\nmodel = \"original-model\"\n";
write_text(&config_path(&codex_dir), original_config).expect("write official config");
let error = save_official_config_inner(
Some(codex_dir.display().to_string()),
Some("changed-model".to_string()),
Some(
json!({
"auth_mode": "chatgpt",
"tokens": {"access_token": "", "refresh_token": ""}
})
.to_string(),
),
)
.expect_err("empty auth must be rejected");
assert!(error.to_string().contains("有效认证信息"));
assert_eq!(
fs::read_to_string(config_path(&codex_dir)).expect("read unchanged config"),
original_config
);
assert!(!auth_path(&codex_dir).exists());
let _ = fs::remove_dir_all(codex_dir);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.1",
"version": "0.3.5",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+3 -1
View File
@@ -65,6 +65,7 @@ export type AppShellProps = {
updatePhase?: AppUpdaterPhase;
onOpenUpdate?: () => void;
isMacRuntime?: boolean;
skinCenterEnabled?: boolean;
children: ReactNode;
sidebarFooter?: ReactNode;
className?: string;
@@ -83,6 +84,7 @@ export function AppShell({
updatePhase = "idle",
onOpenUpdate,
isMacRuntime = false,
skinCenterEnabled = true,
children,
sidebarFooter,
className,
@@ -158,7 +160,7 @@ export function AppShell({
</div>
<nav className="cx-sidebar-nav" aria-label={navigationLabel}>
{NAV_ITEMS.map((item) => {
{NAV_ITEMS.filter((item) => item.id !== "skins" || skinCenterEnabled).map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
+16 -7
View File
@@ -25,6 +25,7 @@ function exportFileName(value: string) {
}
type UseSkinCenterOptions = {
enabled: boolean;
lang: Lang;
tab: AppTab;
ready: boolean;
@@ -34,6 +35,7 @@ type UseSkinCenterOptions = {
};
export function useSkinCenter({
enabled,
lang,
tab,
ready,
@@ -43,14 +45,17 @@ export function useSkinCenter({
}: UseSkinCenterOptions) {
const [state, setState] = React.useState<SkinCenterState | null>(null);
const [restartRequest, setRestartRequest] = React.useState<SkinRestartRequest | null>(null);
const [pauseBusy, setPauseBusy] = React.useState(false);
const zipInputRef = React.useRef<HTMLInputElement | null>(null);
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
const loadedRef = React.useRef(false);
const pauseInFlightRef = React.useRef(false);
const load = React.useCallback(async ({
quiet = false,
notify = false,
}: { quiet?: boolean; notify?: boolean } = {}) => {
if (!enabled) return;
if (!quiet) {
setActionBusy("loadSkins");
setError("");
@@ -65,21 +70,21 @@ export function useSkinCenter({
} finally {
if (!quiet) setActionBusy("");
}
}, [lang, setActionBusy, setError, setToast]);
}, [enabled, lang, setActionBusy, setError, setToast]);
const refresh = React.useCallback(() => load({ notify: true }), [load]);
React.useEffect(() => {
if (tab !== "skins" || loadedRef.current) return;
if (!enabled || tab !== "skins" || loadedRef.current) return;
loadedRef.current = true;
void load();
}, [load, tab]);
}, [enabled, load, tab]);
React.useEffect(() => {
if (!ready || loadedRef.current) return;
if (!enabled || !ready || loadedRef.current) return;
loadedRef.current = true;
void load({ quiet: true });
}, [load, ready]);
}, [enabled, load, ready]);
const importZip = async (file?: File | null): Promise<string | null> => {
if (!file) return null;
@@ -193,7 +198,9 @@ export function useSkinCenter({
};
const pause = async () => {
setActionBusy("pauseSkin");
if (pauseInFlightRef.current) return;
pauseInFlightRef.current = true;
setPauseBusy(true);
setError("");
try {
const result = await invoke<SkinActionResult>("pause_skin_theme");
@@ -203,7 +210,8 @@ export function useSkinCenter({
setError(String(error));
void load({ quiet: true });
} finally {
setActionBusy("");
pauseInFlightRef.current = false;
setPauseBusy(false);
}
};
@@ -236,6 +244,7 @@ export function useSkinCenter({
return {
state,
restartRequest,
pauseBusy,
zipInputRef,
imageInputRef,
refresh,
+161 -12
View File
@@ -28,12 +28,14 @@ import type {
AboutInfo,
ActionResult,
AppUpdateInfo,
BuiltinPromptDetail,
BuiltinPromptStatus,
CodexState,
ImportResult,
InstructionMode,
InstructionTemplate,
Lang,
OfficialConfigDraft,
PromptInjectionMode,
ProviderConnectionResult,
ProviderModel,
@@ -63,6 +65,7 @@ const STARTUP_WIZARD_SEEN_KEY = "codexx.startupWizardSeen";
const ACTIVE_PROVIDER_KEY = "codexx.activeProviderId";
const PROMPT_INJECTION_MODE_KEY = "codexx.promptInjectionMode";
const FALLBACK_GITHUB_REPO = "yynxxxxx/Codex-X";
const SKIN_CENTER_ENABLED = false;
type ThemeTransitionDocument = Document & {
startViewTransition?: (update: () => void | Promise<void>) => { finished: Promise<void> };
@@ -189,7 +192,7 @@ const dict = {
local: "本地保存",
noProviders: "还没有供应商,点击右上角 + 添加。",
officialEdit: "OpenAI Official 编辑",
officialHint: "官方配置不使用第三方路由;这里可以编辑官方模式下的模型和完整 auth.json(ChatGPT 登录通常包含 access_token / refresh_token / id_token)。",
officialHint: "官方配置与当前中转配置独立保存。还原只恢复 Codex-X 官方快照,不会切换当前供应商;切换回官方时会优先使用该快照。",
officialUrl: "官方入口",
formAdd: "添加新供应商",
formEdit: "编辑供应商",
@@ -291,7 +294,7 @@ const dict = {
local: "Local",
noProviders: "No provider yet. Click + to add one.",
officialEdit: "OpenAI Official settings",
officialHint: "Official mode does not use third-party routing. You can edit the official model and the full auth.json (ChatGPT login usually contains access_token / refresh_token / id_token).",
officialHint: "Official settings are stored separately from the active proxy. Restore only recovers the Codex-X snapshot without switching providers; switching back uses that snapshot first.",
officialUrl: "Official URL",
formAdd: "Add provider",
formEdit: "Edit provider",
@@ -375,6 +378,14 @@ function getProviderPageCopy(lang: Lang): ProviderCopy {
officialCurrentLabel: t.provider.current,
officialAuthLabel: "auth.json (JSON)",
officialSaveLabel: isChinese ? "保存官方配置" : "Save official config",
restoreOfficialLabel: isChinese ? "还原官方配置" : "Restore official config",
resetOfficialLabel: isChinese ? "新建官方配置" : "Create official config",
resetOfficialTitle: isChinese ? "新建官方配置" : "Create official config",
resetOfficialDescription: isChinese
? "这会切换到 OpenAI Official、清除当前 live auth.json,并要求你在 Codex 中重新登录。操作前会自动备份当前配置。"
: "This switches to OpenAI Official, removes the live auth.json, and requires a new Codex login. The current files are backed up first.",
resetOfficialCancelLabel: isChinese ? "取消" : "Cancel",
resetOfficialConfirmLabel: isChinese ? "清除并新建" : "Clear and create",
cancelLabel: t.provider.cancel,
formEyebrow: "Provider",
formAddTitle: t.provider.formAdd,
@@ -723,6 +734,11 @@ function TomlPreview({ text }: { text: string }) {
);
}
function fitCodeEditorHeight(editor: HTMLTextAreaElement | null, minHeight: number) {
if (!editor) return;
editor.style.height = "auto";
editor.style.height = `${Math.max(minHeight, editor.scrollHeight + 2)}px`;
}
function App() {
const initialLang = (localStorage.getItem(LANG_KEY) as Lang | null) || "zh";
@@ -733,6 +749,7 @@ function App() {
const t = dict[lang];
const updater = useAppUpdater();
const isMacRuntime = navigator.userAgent.toLowerCase().includes("mac");
const skinCenterEnabled = SKIN_CENTER_ENABLED;
const [tab, setTab] = React.useState<Tab>("dashboard");
const [visitedTabs, setVisitedTabs] = React.useState<Set<Tab>>(() => new Set(["dashboard"]));
const [providerMode, setProviderMode] = React.useState<ProviderMode>("list");
@@ -743,6 +760,7 @@ function App() {
const [skillsMcpTab, setSkillsMcpTab] = React.useState<"mcp" | "skills">("mcp");
const [editingProviderId, setEditingProviderId] = React.useState<string | null>(null);
const [editingPromptId, setEditingPromptId] = React.useState<string | null>(null);
const [editingBuiltinPrompt, setEditingBuiltinPrompt] = React.useState<BuiltinPromptDetail | null>(null);
const [savedProviders, setSavedProviders] = React.useState<SavedProvider[]>([]);
const [activeProviderId, setActiveProviderId] = React.useState(() => localStorage.getItem(ACTIVE_PROVIDER_KEY) || "");
const [savedPrompts, setSavedPrompts] = React.useState<SavedPrompt[]>([]);
@@ -786,6 +804,7 @@ function App() {
const autoUpdateCheckedRef = React.useRef(false);
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
const officialAuthEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const providerTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const providerModelsRequestRef = React.useRef(0);
const promptModeHelpRef = React.useRef<HTMLDivElement | null>(null);
@@ -795,10 +814,12 @@ function App() {
const promptCatalogReadyRef = React.useRef(false);
const promptModeSyncedRef = React.useRef("");
const skillsMcpLoadedRef = React.useRef(false);
const skinShutdownAttemptedRef = React.useRef(false);
const themeTransitionTimerRef = React.useRef<number | null>(null);
const {
state: skinCenterState,
restartRequest: skinRestartRequest,
pauseBusy: skinPauseBusy,
zipInputRef: skinZipImportRef,
imageInputRef: skinImageInputRef,
refresh: refreshSkinCenter,
@@ -811,6 +832,7 @@ function App() {
closeRestart: closeSkinRestart,
exportTheme: exportSkinTheme,
} = useSkinCenter({
enabled: skinCenterEnabled,
lang,
tab,
ready: Boolean(state),
@@ -926,12 +948,20 @@ function App() {
React.useLayoutEffect(() => {
if (providerMode !== "form") return;
const editor = providerTomlEditorRef.current;
if (!editor) return;
editor.style.height = "0px";
editor.style.height = `${Math.max(560, editor.scrollHeight)}px`;
fitCodeEditorHeight(providerTomlEditorRef.current, 560);
}, [providerMode, providerTomlDraft]);
React.useEffect(() => {
if (providerMode !== "official") return;
const fit = () => fitCodeEditorHeight(officialAuthEditorRef.current, 420);
const frame = window.requestAnimationFrame(fit);
window.addEventListener("resize", fit);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", fit);
};
}, [officialForm.authJson, providerMode]);
React.useEffect(() => {
if (!state || promptModeSyncedRef.current === state.codexDir) return;
promptModeSyncedRef.current = state.codexDir;
@@ -949,6 +979,16 @@ function App() {
});
}, [tab]);
React.useEffect(() => {
if (skinCenterEnabled || skinShutdownAttemptedRef.current) return;
skinShutdownAttemptedRef.current = true;
void invoke("pause_skin_theme").catch((error) => {
setError(lang === "zh"
? `皮肤中心已暂时关闭,但未能自动停用现有皮肤:${String(error)}`
: `The skin center is temporarily disabled, but the active skin could not be stopped: ${String(error)}`);
});
}, [lang, skinCenterEnabled]);
React.useEffect(() => {
if (providerMode === "form" && !providerTomlDirty) {
setProviderTomlDraft(providerTomlPreview);
@@ -1279,16 +1319,34 @@ function App() {
const openAddPrompt = () => {
setEditingPromptId(null);
setEditingBuiltinPrompt(null);
setPromptForm({ ...blankPromptForm });
setInstructionMode("form");
};
const openEditPrompt = (prompt: SavedPrompt) => {
setEditingPromptId(prompt.id);
setEditingBuiltinPrompt(null);
setPromptForm(prompt);
setInstructionMode("form");
};
const openEditBuiltinPrompt = (templateId: string) =>
call(
() => invoke<BuiltinPromptDetail>("get_builtin_prompt_detail", { templateId }),
(detail) => {
setEditingPromptId(null);
setEditingBuiltinPrompt(detail);
setPromptForm({
id: detail.id,
title: detail.title,
filename: detail.filename,
content: detail.content,
});
setInstructionMode("form");
},
);
const normalizedPromptForm = (): SavedPrompt => {
const existing = savedPrompts.filter((item) => item.id !== editingPromptId);
const requestedFilename = promptForm.filename.trim() || `${providerId(promptForm.title || "prompt")}.md`;
@@ -1302,7 +1360,30 @@ function App() {
};
};
const savePromptOnly = () =>
const savePromptOnly = () => {
if (editingBuiltinPrompt) {
call(
async () => {
const detail = await invoke<BuiltinPromptDetail>("save_builtin_prompt_override", {
templateId: editingBuiltinPrompt.id,
content: promptForm.content,
});
const statuses = await invoke<BuiltinPromptStatus[]>("get_builtin_prompt_status");
return { detail, statuses };
},
({ detail, statuses }) => {
setEditingBuiltinPrompt(detail);
setBuiltinPromptStatus(uniqueBuiltinPromptStatuses(statuses));
setInstructionMode("list");
setToast(detail.customized
? (lang === "zh"
? "本地修改已保存,下次启用时生效;后续 GitHub 同步将跳过这个模板"
: "Local changes saved for the next activation. Future GitHub syncs will skip this template.")
: (lang === "zh" ? "内容没有变化,模板将继续参与 GitHub 同步" : "No changes detected. This template will continue to sync from GitHub."));
},
);
return;
}
call(
async () => {
await invoke<SavedPrompt>("save_prompt", { prompt: normalizedPromptForm() });
@@ -1315,6 +1396,7 @@ function App() {
setToast(lang === "zh" ? "提示词已保存" : "Prompt saved");
},
);
};
const enableSavedPrompt = (id: string) =>
call(() => invoke<ActionResult>("enable_saved_prompt", { configDir: configDir || null, id, injectionMode: promptInjectionMode }), handleActionResult);
@@ -1543,6 +1625,46 @@ function App() {
},
);
const restoreOfficialProvider = () =>
call(
async () => {
const result = await invoke<ActionResult>("restore_official_provider", { configDir: configDir || null });
const draft = await invoke<OfficialConfigDraft | null>("get_official_config_draft", {
configDir: configDir || null,
});
return { result, draft };
},
({ result, draft }) => {
if (draft) {
setOfficialForm({
model: draft.model || "gpt-5.5",
authJson: draft.authJson,
});
}
handleActionResult(result);
},
);
const resetOfficialProvider = () =>
call(
() => invoke<ActionResult>("reset_official_provider", {
input: {
configDir: configDir || null,
model: officialForm.model,
authJson: null,
},
}),
(result) => {
localStorage.removeItem(ACTIVE_PROVIDER_KEY);
setActiveProviderId("");
setOfficialForm({
model: result.state.model || officialForm.model || "gpt-5.5",
authJson: officialAuthPlaceholder,
});
handleActionResult(result);
},
);
const importFromCcSwitch = async () => {
setActionBusy("importCcSwitch");
try {
@@ -1778,12 +1900,30 @@ function App() {
const officialAuthPlaceholder = '{\n "OPENAI_API_KEY": null,\n "auth_mode": "chatgpt",\n "tokens": {\n "access_token": "",\n "refresh_token": "",\n "id_token": ""\n }\n}';
const openOfficialEdit = () => {
const openOfficialEdit = async () => {
const liveIsOfficial = !state?.modelProvider || state.modelProvider === "openai";
setOfficialForm({
model: state?.model || "gpt-5.5",
authJson: state?.authText || officialAuthPlaceholder,
authJson: liveIsOfficial && state?.authText ? state.authText : officialAuthPlaceholder,
});
setProviderMode("official");
setActionBusy("loadOfficialDraft");
setError("");
try {
const draft = await invoke<OfficialConfigDraft | null>("get_official_config_draft", {
configDir: configDir || null,
});
if (draft) {
setOfficialForm({
model: draft.model || state?.model || "gpt-5.5",
authJson: draft.authJson,
});
}
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const saveOfficialConfig = () =>
@@ -1961,6 +2101,7 @@ function App() {
<AppShell
activeTab={tab}
onTabChange={(nextTab) => {
if (nextTab === "skins" && !skinCenterEnabled) return;
if (!state && nextTab !== "dashboard" && nextTab !== "settings" && nextTab !== "about") {
setTab("dashboard");
return;
@@ -1976,6 +2117,7 @@ function App() {
updatePhase={updater.state.phase}
onOpenUpdate={() => setUpdatePromptOpen(true)}
isMacRuntime={isMacRuntime}
skinCenterEnabled={skinCenterEnabled}
contentClassName={cx(
tab === "sessions" && "cx-app-content--sessions",
(
@@ -2009,7 +2151,7 @@ function App() {
}}
/>
<SkinRestartDialog
open={Boolean(skinRestartRequest)}
open={skinCenterEnabled && Boolean(skinRestartRequest)}
lang={lang}
themeName={skinRestartRequest?.themeName}
busy={actionBusy.startsWith("skin:")}
@@ -2044,6 +2186,7 @@ function App() {
providerLabel={currentProvider?.name || state?.modelProvider}
instructionEnabled={Boolean(state?.instructionEnabled)}
authExists={Boolean(state?.authExists)}
officialAuthAvailable={Boolean(state?.officialAuthAvailable)}
configPath={state?.configPath}
modelProvider={state?.modelProvider}
instructionPath={state
@@ -2079,6 +2222,7 @@ function App() {
requiresOpenaiAuth: providerForm.requiresOpenaiAuth,
}}
officialForm={officialForm}
officialAuthRef={officialAuthEditorRef}
officialInfo={{
officialUrl: "https://chatgpt.com/codex",
authPath: state.authPath,
@@ -2115,7 +2259,7 @@ function App() {
}}
onEditProvider={(row) => {
if (row.source === "official") {
openOfficialEdit();
void openOfficialEdit();
return;
}
const local = findLocalProviderForRow(row);
@@ -2126,6 +2270,8 @@ function App() {
const local = findLocalProviderForRow(row);
return local ? removeProvider(local.id) : Promise.resolve(false);
}}
onRestoreOfficial={restoreOfficialProvider}
onResetOfficial={resetOfficialProvider}
onCancelMode={() => setProviderMode("list")}
onOfficialModelChange={(value) => setOfficialForm((current) => ({ ...current, model: value }))}
onOfficialAuthChange={(value) => setOfficialForm((current) => ({ ...current, authJson: value }))}
@@ -2232,12 +2378,13 @@ function App() {
/>
)}
{state && (tab === "skins" || visitedTabs.has("skins")) && (
{skinCenterEnabled && state && (tab === "skins" || visitedTabs.has("skins")) && (
<div className={cx("cx-skins-pane", tab !== "skins" && "page-pane-hidden")}>
<SkinsPage
lang={lang}
state={skinCenterState}
actionBusy={actionBusy}
pauseBusy={skinPauseBusy}
zipInputRef={skinZipImportRef}
imageInputRef={skinImageInputRef}
onLoad={refreshSkinCenter}
@@ -2257,6 +2404,7 @@ function App() {
instructionMode={instructionMode}
promptForm={promptForm}
editingPromptId={editingPromptId}
editingBuiltinPrompt={editingBuiltinPrompt}
loading={loading}
actionBusy={actionBusy}
promptSyncing={promptSyncing}
@@ -2310,6 +2458,7 @@ function App() {
onEnableSavedPrompt={enableSavedPrompt}
onDisableExternalPrompt={disableExternalInstruction}
onEditPrompt={openEditPrompt}
onEditBuiltinPrompt={openEditBuiltinPrompt}
onDeletePrompt={removeSavedPrompt}
onPromptFormFieldChange={(field, value) => setPromptForm((current) => ({
...current,
+14 -6
View File
@@ -21,6 +21,7 @@ export type OverviewPageProps = {
providerLabel?: string | null;
instructionEnabled: boolean;
authExists: boolean;
officialAuthAvailable: boolean;
configPath?: string | null;
modelProvider?: string | null;
instructionPath?: string | null;
@@ -76,6 +77,7 @@ export function OverviewPage({
providerLabel,
instructionEnabled,
authExists,
officialAuthAvailable,
configPath,
modelProvider,
instructionPath,
@@ -103,8 +105,9 @@ export function OverviewPage({
instruction: "指令提示词",
enabled: "已启用",
disabled: "未启用",
auth: "认证文件",
authFile: "auth.json",
auth: "认证状态",
authFile: "auth.json 已找到",
officialAuth: "官方认证已保存",
noAuth: "未找到",
updateFound: "发现新版本",
updateAvailable: (version: string) => `Codex-X ${version} 已发布`,
@@ -134,8 +137,9 @@ export function OverviewPage({
instruction: "Instructions",
enabled: "Enabled",
disabled: "Disabled",
auth: "Auth file",
authFile: "auth.json",
auth: "Authentication",
authFile: "auth.json found",
officialAuth: "Official auth saved",
noAuth: "Not found",
updateFound: "New version available",
updateAvailable: (version: string) => `Codex-X ${version} is available`,
@@ -159,6 +163,10 @@ export function OverviewPage({
const displayInstructionPath = instructionPath?.trim() || text.notConfigured;
const updateVersion = latestVersion?.trim() || "";
const homeInputValue = configDir || resolvedCodexDir;
const authAvailable = authExists || officialAuthAvailable;
const authStatus = authExists
? text.authFile
: officialAuthAvailable ? text.officialAuth : text.noAuth;
return (
<section className="cx-overview-page" aria-label={isChinese ? "概览" : "Overview"}>
@@ -227,8 +235,8 @@ export function OverviewPage({
<StatusCard
icon={KeyRound}
label={text.auth}
value={authExists ? text.authFile : text.noAuth}
tone={authExists ? "success" : "muted"}
value={authStatus}
tone={authAvailable ? "success" : "muted"}
/>
</div>
+52 -7
View File
@@ -23,6 +23,7 @@ import type { PromptCategoryItem } from "../components/PromptCategoryManager";
import { Button, IconButton, StatusBadge, Toggle, cx } from "../components/ui";
import { usePromptCategories } from "../promptCategories";
import type {
BuiltinPromptDetail,
BuiltinPromptStatus,
InstructionMode,
InstructionTemplate,
@@ -53,6 +54,7 @@ export type PromptsPageProps = {
instructionMode: InstructionMode;
promptForm: SavedPrompt;
editingPromptId: string | null;
editingBuiltinPrompt: BuiltinPromptDetail | null;
loading: boolean;
actionBusy: string;
promptSyncing: boolean;
@@ -84,6 +86,7 @@ export type PromptsPageProps = {
onEnableSavedPrompt: (id: string) => void | Promise<void>;
onDisableExternalPrompt: MaybeAsyncAction;
onEditPrompt: (prompt: SavedPrompt) => void;
onEditBuiltinPrompt: (id: string) => void | Promise<void>;
onDeletePrompt: (id: string) => void | Promise<void>;
onPromptFormFieldChange: (field: PromptFormField, value: string) => void;
onSavePrompt: MaybeAsyncAction;
@@ -129,11 +132,17 @@ function getCopy(lang: Lang) {
preservedDescription: "用户原有提示词,追加模式下继续生效。",
existingPrompt: "用户原有指令提示词",
edit: "编辑",
editBuiltin: "查看/编辑本地模板",
remove: "删除",
formEyebrow: "CUSTOM PROMPT",
addFormTitle: "添加提示词",
editFormTitle: "编辑提示词",
formDescription: "保存为 Markdown 文件,之后可在列表中单独启用。",
builtinFormEyebrow: "LOCAL TEMPLATE",
builtinFormTitle: "查看与编辑本地模板",
builtinFormDescription: "名称和文件名跟随模板目录;内容修改在下次启用时生效,之后 GitHub 同步将永久跳过这份模板。",
locallyModified: "本地已修改",
saveLocalChanges: "保存本地修改",
back: "返回",
promptDetails: "提示词详情",
promptName: "提示词名称",
@@ -183,11 +192,17 @@ function getCopy(lang: Lang) {
preservedDescription: "Existing user prompt preserved by append mode.",
existingPrompt: "Existing user prompt",
edit: "Edit",
editBuiltin: "View or edit local template",
remove: "Delete",
formEyebrow: "CUSTOM PROMPT",
addFormTitle: "Add prompt",
editFormTitle: "Edit prompt",
formDescription: "Save it as Markdown, then enable it separately from the list.",
builtinFormEyebrow: "LOCAL TEMPLATE",
builtinFormTitle: "View and edit local template",
builtinFormDescription: "The name and filename follow the template catalog. Content changes apply on the next activation, then GitHub sync permanently skips this template.",
locallyModified: "Locally modified",
saveLocalChanges: "Save local changes",
back: "Back",
promptDetails: "Prompt details",
promptName: "Prompt name",
@@ -269,6 +284,7 @@ function PromptFormView({
lang,
promptForm,
editingPromptId,
editingBuiltinPrompt,
loading,
onInstructionModeChange,
onPromptFormFieldChange,
@@ -278,6 +294,7 @@ function PromptFormView({
| "lang"
| "promptForm"
| "editingPromptId"
| "editingBuiltinPrompt"
| "loading"
| "onInstructionModeChange"
| "onPromptFormFieldChange"
@@ -287,14 +304,15 @@ function PromptFormView({
const titleId = useId();
const filenameId = useId();
const contentId = useId();
const isBuiltin = Boolean(editingBuiltinPrompt);
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>
<p><PencilLine size={14} aria-hidden="true" />{isBuiltin ? copy.builtinFormEyebrow : copy.formEyebrow}</p>
<h2>{isBuiltin ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}</h2>
<span>{isBuiltin ? copy.builtinFormDescription : copy.formDescription}</span>
</div>
<Button
variant="secondary"
@@ -310,6 +328,9 @@ function PromptFormView({
<div className="cx-prompts-form-panel-head">
<FileText size={18} aria-hidden="true" />
<h3 id={`${titleId}-panel`}>{copy.promptDetails}</h3>
{editingBuiltinPrompt?.customized && (
<StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>
)}
</div>
<div className="cx-prompts-form-grid">
<label className="cx-prompts-field" htmlFor={titleId}>
@@ -320,6 +341,7 @@ function PromptFormView({
value={promptForm.title}
onChange={(event) => onPromptFormFieldChange("title", event.currentTarget.value)}
placeholder={copy.promptNamePlaceholder}
readOnly={isBuiltin}
disabled={loading}
autoComplete="off"
/>
@@ -332,6 +354,7 @@ function PromptFormView({
value={promptForm.filename}
onChange={(event) => onPromptFormFieldChange("filename", event.currentTarget.value)}
placeholder={copy.filenamePlaceholder}
readOnly={isBuiltin}
disabled={loading}
autoComplete="off"
spellCheck={false}
@@ -358,7 +381,7 @@ function PromptFormView({
onClick={() => run(onSavePrompt)}
disabled={loading}
>
{copy.save}
{isBuiltin ? copy.saveLocalChanges : copy.save}
</Button>
</div>
</div>
@@ -370,6 +393,7 @@ export function PromptsPage({
instructionMode,
promptForm,
editingPromptId,
editingBuiltinPrompt,
loading,
actionBusy,
promptSyncing,
@@ -382,6 +406,7 @@ export function PromptsPage({
activeInstructionTitle,
activeInjectionMode,
instructionTemplates,
builtinPromptStatuses,
activeBuiltinTemplateId,
orphanedBuiltinPrompt,
savedPrompts,
@@ -400,6 +425,7 @@ export function PromptsPage({
onEnableSavedPrompt,
onDisableExternalPrompt,
onEditPrompt,
onEditBuiltinPrompt,
onDeletePrompt,
onPromptFormFieldChange,
onSavePrompt,
@@ -412,6 +438,10 @@ export function PromptsPage({
const importBusy = actionBusy === "importPrompt";
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
const promptCategories = usePromptCategories(lang);
const builtinStatusById = useMemo(
() => new Map(builtinPromptStatuses.map((status) => [status.id, status])),
[builtinPromptStatuses],
);
const categoryItems = useMemo<PromptCategoryItem[]>(() => [
...instructionTemplates.map((template) => ({
key: promptCategoryKey("builtin", template.id),
@@ -441,11 +471,13 @@ export function PromptsPage({
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}>
<section className={cx("cx-prompts-page", "cx-prompts-page--form", className)} aria-label={editingBuiltinPrompt ? copy.builtinFormTitle : editingPromptId ? copy.editFormTitle : copy.addFormTitle}>
<PromptFormView
key={editingBuiltinPrompt?.id || editingPromptId || "new"}
lang={lang}
promptForm={promptForm}
editingPromptId={editingPromptId}
editingBuiltinPrompt={editingBuiltinPrompt}
loading={loading}
onInstructionModeChange={onInstructionModeChange}
onPromptFormFieldChange={onPromptFormFieldChange}
@@ -605,6 +637,7 @@ export function PromptsPage({
{instructionTemplates.filter((template) =>
promptIsVisible(promptCategoryKey("builtin", template.id))).map((template) => {
const enabled = template.id === activeBuiltinTemplateId;
const customized = Boolean(builtinStatusById.get(template.id)?.customized);
return (
<PromptRow
key={template.id}
@@ -614,10 +647,22 @@ export function PromptsPage({
loading={loading}
toggleLabel={enabled ? copy.disable : copy.enable}
onToggle={() => enabled ? onDisableInstruction() : onEnableBuiltinPrompt(template.id)}
actions={(
<div className="cx-prompts-icon-actions">
<IconButton
icon={<PencilLine size={15} />}
label={copy.editBuiltin}
size="sm"
onClick={() => onEditBuiltinPrompt(template.id)}
disabled={loading}
/>
</div>
)}
>
{enabled && (
{(enabled || customized) && (
<div className="cx-prompts-row-meta">
<StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>
{enabled && <StatusBadge tone="accent" dot={false}>{copy.current} · {activeModeLabel}</StatusBadge>}
{customized && <StatusBadge tone="info" dot={false}>{copy.locallyModified}</StatusBadge>}
</div>
)}
</PromptRow>
+60 -4
View File
@@ -7,10 +7,12 @@ import {
CheckCircle2,
Eye,
EyeOff,
FilePlus2,
Loader2,
PencilLine,
Plus,
RefreshCw,
RotateCcw,
Trash2,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -80,6 +82,12 @@ export type ProviderCopy = {
officialCurrentLabel: string;
officialAuthLabel: string;
officialSaveLabel: string;
restoreOfficialLabel: string;
resetOfficialLabel: string;
resetOfficialTitle: string;
resetOfficialDescription: string;
resetOfficialCancelLabel: string;
resetOfficialConfirmLabel: string;
cancelLabel: string;
formEyebrow: string;
formAddTitle: string;
@@ -125,6 +133,7 @@ export type ProvidersPageProps = {
editingProviderId: string | null;
providerForm: ProviderFormValue;
officialForm: OfficialFormValue;
officialAuthRef?: Ref<HTMLTextAreaElement>;
officialInfo: ProviderOfficialInfo;
providerAuthPreview: ReactNode;
providerTomlDraft: string;
@@ -134,6 +143,8 @@ export type ProvidersPageProps = {
fetchingModels: boolean;
onImportCcSwitch: () => void;
onAddProvider: () => void;
onRestoreOfficial: () => void;
onResetOfficial: () => void;
onEnableProvider: (row: ProviderRow) => void;
onTestProvider: (row: ProviderRow) => void;
onEditProvider: (row: ProviderRow) => void;
@@ -220,11 +231,12 @@ function ListPage({
actionBusy,
onImportCcSwitch,
onAddProvider,
onRestoreOfficial,
onEnableProvider,
onTestProvider,
onEditProvider,
onDeleteProvider,
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDeleteProvider">) {
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
const [deleting, setDeleting] = useState(false);
@@ -298,6 +310,14 @@ function ListPage({
>
{copy.enableLabel}
</button>
{row.source === "official" && (
<ActionIconButton
icon={RotateCcw}
label={copy.restoreOfficialLabel}
onClick={onRestoreOfficial}
disabled={loading}
/>
)}
{row.testable !== false && (
<ActionIconButton
icon={isTesting ? Loader2 : Activity}
@@ -373,13 +393,23 @@ function ModeHeader({ eyebrow, title, description, cancelLabel, onCancel }: { ey
function OfficialForm({
copy,
officialForm,
officialAuthRef,
officialInfo,
loading,
onCancelMode,
onOfficialModelChange,
onOfficialAuthChange,
onSaveOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial">) {
onRestoreOfficial,
onResetOfficial,
}: Pick<ProvidersPageProps, "copy" | "officialForm" | "officialAuthRef" | "officialInfo" | "loading" | "onCancelMode" | "onOfficialModelChange" | "onOfficialAuthChange" | "onSaveOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const confirmReset = () => {
setResetConfirmOpen(false);
onResetOfficial();
};
return (
<>
<ModeHeader eyebrow={copy.officialEyebrow} title={copy.officialTitle} description={copy.officialHint} cancelLabel={copy.cancelLabel} onCancel={onCancelMode} />
@@ -393,16 +423,42 @@ function OfficialForm({
</div>
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
<textarea
ref={officialAuthRef}
className="cx-providers-code-editor cx-providers-auth-editor"
value={officialForm.authJson}
onChange={(event) => onOfficialAuthChange(event.target.value)}
wrap="off"
wrap="soft"
spellCheck={false}
/>
</Field>
<div className="cx-providers-form-actions cx-providers-form-actions--save">
<div className="cx-providers-form-actions cx-providers-form-actions--save cx-providers-official-actions">
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={loading}>
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={loading}>
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
</button>
<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>
<ModalShell
open={resetConfirmOpen}
onClose={() => setResetConfirmOpen(false)}
title={copy.resetOfficialTitle}
description={copy.resetOfficialDescription}
size="sm"
closeLabel={copy.resetOfficialCancelLabel}
footer={(
<>
<Button variant="secondary" onClick={() => setResetConfirmOpen(false)} data-initial-focus>{copy.resetOfficialCancelLabel}</Button>
<Button variant="danger" icon={<FilePlus2 size={16} />} onClick={confirmReset}>{copy.resetOfficialConfirmLabel}</Button>
</>
)}
>
<div className="cx-provider-delete-warning">
<span aria-hidden="true"><AlertTriangle size={22} /></span>
<strong>auth.json</strong>
</div>
</ModalShell>
</>
);
}
+12 -9
View File
@@ -46,6 +46,7 @@ export type SkinsPageProps = {
lang: Lang;
state: SkinCenterState | null;
actionBusy: string;
pauseBusy: boolean;
zipInputRef: RefObject<HTMLInputElement>;
imageInputRef: RefObject<HTMLInputElement>;
onLoad: MaybeAsyncAction;
@@ -226,6 +227,7 @@ export function SkinsPage({
lang,
state,
actionBusy,
pauseBusy,
zipInputRef,
imageInputRef,
onLoad,
@@ -240,6 +242,7 @@ export function SkinsPage({
const themes = state?.themes ?? [];
const current = themes.find((theme) => theme.enabled);
const loading = actionBusy === "loadSkins";
const controlsBusy = Boolean(actionBusy) || pauseBusy;
const runtime = state?.runtime;
const [categoryManagerOpen, setCategoryManagerOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
@@ -327,7 +330,7 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => setEditorOpen(true)}
disabled={Boolean(actionBusy)}
disabled={controlsBusy}
icon={<Pencil size={15} />}
>
{copy.edit}
@@ -335,7 +338,7 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => run(() => onEnableTheme(selectedTheme.id))}
disabled={Boolean(actionBusy) || selectedThemeApplied}
disabled={controlsBusy || selectedThemeApplied}
icon={actionBusy === `skin:${selectedTheme.id}`
? <Loader2 className="cx-skins-spin" size={15} />
: selectedThemeApplied ? <Check size={15} /> : <Play size={15} />}
@@ -347,7 +350,7 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => run(() => onExportTheme(selectedTheme.id))}
disabled={Boolean(actionBusy)}
disabled={controlsBusy}
icon={actionBusy === `skinExport:${selectedTheme.id}`
? <Loader2 className="cx-skins-spin" size={15} />
: <Download size={15} />}
@@ -359,15 +362,15 @@ export function SkinsPage({
<Button
variant="secondary"
onClick={() => run(onPauseTheme)}
disabled={Boolean(actionBusy) || !runtime?.supported || !runtime.active}
icon={actionBusy === "pauseSkin" ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
disabled={pauseBusy}
icon={pauseBusy ? <Loader2 className="cx-skins-spin" size={15} /> : <Power size={15} />}
>
{actionBusy === "pauseSkin" ? copy.pausing : copy.pause}
{pauseBusy ? copy.pausing : copy.pause}
</Button>
<Button
variant="secondary"
onClick={() => zipInputRef.current?.click()}
disabled={Boolean(actionBusy) || !state}
disabled={controlsBusy || !state}
icon={actionBusy === "importSkinZip" ? <Loader2 className="cx-skins-spin" size={15} /> : <Upload size={15} />}
>
{copy.importZip}
@@ -375,7 +378,7 @@ export function SkinsPage({
<Button
variant="primary"
onClick={() => imageInputRef.current?.click()}
disabled={Boolean(actionBusy) || !state}
disabled={controlsBusy || !state}
icon={actionBusy === "createSkinFromImage"
? <Loader2 className="cx-skins-spin" size={15} />
: <ImagePlus size={15} />}
@@ -401,7 +404,7 @@ export function SkinsPage({
variant="ghost"
size="sm"
onClick={() => run(onLoad)}
disabled={Boolean(actionBusy)}
disabled={controlsBusy}
/>
</section>
+19 -4
View File
@@ -22,8 +22,16 @@ type PromptCategoryState = {
export type ManagedCategoryOptions = {
storageKey: string;
defaultCategories: (lang: Lang) => PromptCategory[];
defaultCategoryForPrompt?: (promptKey: string) => string | undefined;
};
export function defaultPromptCategoryForKey(promptKey: string) {
const normalized = promptKey.trim().toLowerCase();
if (normalized.includes("software-development-")) return "software-development";
if (normalized.includes("writing-")) return "writing";
return undefined;
}
function defaultPromptCategories(lang: Lang): PromptCategory[] {
return lang === "zh"
? [
@@ -41,6 +49,7 @@ function defaultPromptCategories(lang: Lang): PromptCategory[] {
const PROMPT_CATEGORY_OPTIONS: ManagedCategoryOptions = {
storageKey: PROMPT_STORAGE_KEY,
defaultCategories: defaultPromptCategories,
defaultCategoryForPrompt: defaultPromptCategoryForKey,
};
function initialState(lang: Lang, options: ManagedCategoryOptions): PromptCategoryState {
@@ -151,9 +160,11 @@ export function useManagedCategories(lang: Lang, options: ManagedCategoryOptions
const categoryForPrompt = React.useCallback(
(promptKey: string) => {
const assigned = state.assignments[promptKey];
return assigned && categoryIds.has(assigned) ? assigned : state.defaultCategoryId;
if (assigned && categoryIds.has(assigned)) return assigned;
const inferred = options.defaultCategoryForPrompt?.(promptKey);
return inferred && categoryIds.has(inferred) ? inferred : state.defaultCategoryId;
},
[categoryIds, state.assignments, state.defaultCategoryId],
[categoryIds, options, state.assignments, state.defaultCategoryId],
);
const setActiveCategoryId = React.useCallback((categoryId: string) => {
@@ -224,11 +235,15 @@ export function useManagedCategories(lang: Lang, options: ManagedCategoryOptions
if (!promptKey || !categoryIds.has(categoryId)) return;
setState((current) => {
const assignments = { ...current.assignments };
if (categoryId === current.defaultCategoryId) delete assignments[promptKey];
const inferred = options.defaultCategoryForPrompt?.(promptKey);
const naturalCategoryId = inferred && categoryIds.has(inferred)
? inferred
: current.defaultCategoryId;
if (categoryId === naturalCategoryId) delete assignments[promptKey];
else assignments[promptKey] = categoryId;
return { ...current, assignments };
});
}, [categoryIds]);
}, [categoryIds, options]);
const forgetPrompt = React.useCallback((promptKey: string) => {
if (!promptKey) return;
+10 -3
View File
@@ -702,9 +702,12 @@
}
.cx-providers-auth-editor {
overflow: auto;
white-space: pre;
overflow-wrap: normal;
min-height: 420px;
overflow-x: hidden;
overflow-y: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-all;
}
.cx-providers-toml-editor {
@@ -719,6 +722,10 @@
justify-content: flex-end;
}
.cx-providers-official-actions {
margin-top: 18px;
}
.cx-providers-spin {
animation: cx-providers-spin 1s linear infinite;
}
@@ -1288,6 +1288,10 @@
letter-spacing: 0;
}
.cx-prompts-form-panel-head .ui-status-badge {
margin-left: auto;
}
.cx-prompts-form-grid {
min-width: 0;
display: grid;
@@ -1362,6 +1366,12 @@
opacity: 0.6;
}
.cx-prompts-field input:read-only:not(:disabled) {
cursor: default;
color: #646971;
background: #f1f3f5;
}
.cx-prompts-field--content {
grid-column: 1 / -1;
}
+15
View File
@@ -53,6 +53,15 @@ export type BuiltinPromptStatus = {
syncIssue?: "catalog" | "content" | null;
checkedAt?: string | null;
message: string;
customized: boolean;
};
export type BuiltinPromptDetail = {
id: string;
filename: string;
title: string;
content: string;
customized: boolean;
};
export type BackupEntry = {
@@ -94,6 +103,12 @@ export type ActionResult = {
state: CodexState;
};
export type OfficialConfigDraft = {
authJson: string;
model?: string;
source: string;
};
export type ImportResult = {
imported: number;
added: number;
@@ -0,0 +1,47 @@
## 审查角色
你是严格、务实的高级代码审查者。审查目标是发现会造成错误行为、回归、安全问题、数据损坏、兼容性故障或维护成本失控的具体问题。
除非用户明确要求修改代码,否则只审查和报告,不直接编辑文件。
## 审查方法
1. 先读取变更差异,再读取相关函数、类型、调用者、测试和配置。
2. 理解修改前后的行为契约,不只检查语法和局部代码。
3. 追踪输入、状态、错误和副作用经过的完整路径。
4. 检查正常路径、失败路径、空值、边界值、并发和重复执行。
5. 检查平台、版本、序列化、数据库和公共接口兼容性。
6. 检查测试是否真的覆盖新行为,而不是只让覆盖率数字增加。
7. 只报告能够用代码和场景解释清楚的问题。
## 重点检查
* 条件判断错误、状态不同步、过期闭包和生命周期问题
* 权限绕过、注入、路径穿越、敏感信息泄漏和不安全默认值
* 非原子写入、部分失败、错误回滚和数据迁移问题
* 竞态、死锁、资源泄漏、无界循环和无界重试
* API、Schema、配置、文件格式和跨平台行为回归
* 吞异常、误报成功、错误信息丢失和不可观察的失败
* 未覆盖关键失败路径或会通过但无法阻止回归的测试
不要把纯个人偏好、无影响的命名差异或格式问题当成缺陷。除非影响理解或会诱发错误,否则不报告样式类意见。
## 严重级别
* `P0`:会造成严重安全事件、广泛数据损坏或服务不可用,必须立即阻止合并。
* `P1`:高概率产生错误行为、安全风险或重要回归,应在合并前修复。
* `P2`:在明确条件下产生缺陷或显著维护风险,建议本次修复。
* `P3`:低影响但真实存在的问题,可排期处理。
## 输出要求
先列发现,按严重级别排序。每条发现必须包含:
* 简短标题
* 文件和尽可能精确的行号
* 触发条件
* 实际影响
* 为什么当前实现会发生该问题
* 可执行的修复方向
然后列出必要的开放问题或假设,最后给出简短变更摘要。若没有发现,明确写“未发现需要阻止合并的问题”,并说明仍未覆盖的测试或残余风险。
@@ -0,0 +1,49 @@
## 角色与目标
你是负责线上质量的高级软件工程师。面对 Bug、崩溃、性能下降、构建失败或行为异常时,目标是找到可证明的根因并完成最小修复,而不是用绕过、重试或吞异常掩盖问题。
默认使用用户的语言沟通。代码、命令、日志、错误信息和标识符保持原样。
## 调试流程
1. 读取项目说明、相关实现、近期差异和现有测试。
2. 明确预期行为、实际行为、触发条件和影响范围。
3. 尽可能稳定复现;无法复现时,先补充低风险诊断信息或构造最小复现。
4. 沿真实调用链追踪数据、状态和副作用,不根据表面症状猜修复点。
5. 建立少量可证伪的根因假设,并用日志、测试、断点、查询或最小实验逐一验证。
6. 找到首次偏离预期的位置,区分根因、传播路径和最终症状。
7. 在权威实现处修复,补充能在修复前失败、修复后通过的回归测试。
8. 运行与风险相匹配的测试、类型检查、Lint 和构建。
## 证据要求
* 结论必须来自代码、运行结果、日志、数据或可重复实验。
* 不得虚构命令输出、接口响应、数据库内容或测试结果。
* 区分已确认事实、合理推断和仍待验证的信息。
* 记录关键触发条件,包括输入、平台、版本、并发、时序、缓存和外部依赖。
* 日志和报告不得泄露 Token、Cookie、密码、私钥或个人数据。
## 修复约束
* 优先复用现有 Service、Hook、Repository、校验和错误处理。
* 避免扩大公共接口、数据结构和持久化格式的变更范围。
* 不通过空 `catch`、无上限重试、硬编码延迟或静默降级隐藏失败。
* 涉及并发时检查竞态、锁顺序、幂等性、取消、超时和重复提交。
* 涉及文件或数据库时检查原子性、部分写入、回滚和崩溃恢复。
* 涉及跨平台行为时分别检查路径、权限、编码、换行符和进程模型。
* 保留用户已有修改,不顺手重构无关模块。
## 验证标准
至少回答:
* 原问题能否稳定复现?
* 根因位于哪里,证据是什么?
* 为什么修复放在这里?
* 修复是否覆盖空值、边界值和失败路径?
* 是否可能影响其他调用者或平台?
* 哪些检查已实际运行,哪些因环境限制未运行?
## 输出格式
先给出结果,再简要说明根因、修改文件、验证结果和剩余风险。若仍被阻塞,指出唯一的实际阻塞条件和下一条最有价值的验证动作。
+192
View File
@@ -0,0 +1,192 @@
## 基本原则
这是长期维护的正式项目,不是一次性 Demo。
修改代码时优先保证:
1. 正确性和安全性
2. 不破坏现有功能
3. 复用现有实现
4. 最小改动
5. 可维护性和可测试性
---
## 编码前
开始修改前,先搜索项目中是否已有类似实现,包括:
* 函数、类、组件
* Service、Repository、Hook
* API 封装
* 类型、常量、配置
* 校验、转换、错误处理逻辑
优先顺序:
1. 直接复用
2. 扩展现有实现
3. 抽取公共逻辑
4. 确实无法复用时再新增
禁止在未检查现有代码的情况下重新实现相同功能。
复杂任务开始前,先简要说明:
* 相关现有实现
* 可复用模块
* 准备修改的文件
* 是否需要新增文件
* 主要风险
小改动无需写冗长计划。
---
## 禁止重复造轮子
禁止:
* 创建与现有功能高度相似的新函数、类或组件
* 复制已有代码后只修改少量内容
* 重复定义类型、常量、枚举、错误码和接口地址
* 绕过现有 Service、Repository 或 API Client 重写调用逻辑
* 在多个位置分别实现同一业务规则
* 引入与现有依赖功能重复的新依赖
相同业务规则应只有一个权威实现。
---
## 文件与函数规模
行数只作为预警,不是硬性限制。禁止为了减少行数而机械拆分。
参考范围:
* 函数建议不超过 80 行
* 函数超过 100 行时检查是否职责过多
* 组件或类建议不超过 500 行
* 业务文件建议不超过 800 行
* 文件超过 1200 行时优先评估拆分
* 文件超过 1500 行且包含多个职责时应拆分
以下情况即使代码不长,也应考虑拆分:
* 一个模块承担多个无关职责
* 嵌套过深或条件分支过多
* 相同逻辑重复出现
* 难以测试、阅读或复用
* 修改一个功能经常影响无关功能
以下文件可适当放宽:
* 自动生成代码
* 类型声明
* 静态配置或数据
* Schema
* 数据库迁移
* 集中式路由或注册文件
拆分必须依据业务职责,不得创建大量无意义的小文件。
---
## 修改原则
* 优先最小改动
* 不做与当前需求无关的重构
* 不为小需求重写整个模块
* 不擅自修改公共接口、数据库结构或返回格式
* 保持现有目录、命名、风格和架构
* 不创建 `new`、`final`、`v2`、`copy`、`backup` 等重复文件
* 不保留废弃代码、注释代码、调试输出和无意义 TODO
* 不随意新增依赖
发现架构问题时,优先采用渐进式改造,不要一次性重写。
---
## 代码设计
每个函数、类、组件和文件应有明确职责。
避免:
* UI、请求、状态和业务逻辑全部写在一个组件
* Controller 同时处理校验、业务和数据库操作
* 巨型 `utils`、`constants`、`types` 文件
* 过深嵌套
* 大量布尔参数
* 隐藏副作用
* 硬编码配置和业务值
* 大量 `any`
* 空 `catch`
* 吞掉异常
不要过度抽象。只有在逻辑重复、稳定或需要复用时才抽取公共模块。
---
## 安全与错误处理
必须考虑:
* 参数非法
* 空值和边界值
* 权限校验
* 网络或数据库失败
* 外部服务超时
* 并发和重复提交
* 文件操作失败
* 敏感信息泄漏
禁止:
* 硬编码密码、Token 或密钥
* 关闭安全校验
* 记录敏感信息
* SQL 注入、命令注入、XSS、路径穿越等明显风险
* 捕获异常后不处理
优先复用项目现有错误类型、日志和权限机制。
---
## 测试与验证
修改完成后检查:
* 是否重复实现已有功能
* 是否出现复制粘贴代码
* 是否存在超长函数或职责混乱
* 是否破坏已有接口
* 是否存在未使用代码或导入
* 是否遗漏错误处理和边界条件
* 是否需要新增或更新测试
根据项目情况实际运行:
* 测试
* Lint
* 类型检查
* 构建
* 格式检查
没有实际运行的检查,不得声称已经通过。
---
## 输出要求
修改多个文件时,先列出文件变更清单。
输出结果应说明:
1. 复用了哪些现有实现
2. 修改、新增或删除了哪些文件
3. 核心改动是什么
4. 是否存在兼容性风险
5. 实际运行了哪些验证命令
不要输出冗长的过程说明,不要无意义重复整个文件。
+37
View File
@@ -0,0 +1,37 @@
## 编辑目标
你是重视准确性和读者体验的中英文编辑。你的任务是在不改变作者核心意思、不增加未经证实事实的前提下,让文本更清晰、自然、紧凑和可信。
优先交付可以直接使用的修订稿,不用大段解释写作理论。
## 基本原则
* 保留原意、事实、立场、数字、引用和专业术语。
* 不虚构数据、来源、案例、经历、结论或作者没有表达的承诺。
* 删除空话、套话、重复、机械过渡和无实际信息的总结。
* 修复歧义、指代不清、逻辑跳跃、句式拖沓和段落失衡。
* 使用目标读者熟悉的词语,避免为了显得专业而堆叠术语。
* 保持同一文档中的称谓、时态、标点、数字和术语一致。
* 不把作者鲜明的语气统一改成模板化或客服式表达。
## 工作流程
1. 判断文本用途、读者、语气和必须保留的信息。
2. 找出主旨、支撑信息和行动要求,调整信息顺序。
3. 先解决事实与逻辑问题,再处理句子和措辞。
4. 合并重复内容,拆分负担过重的长句和段落。
5. 检查标题、开头、段落衔接和结尾是否承担明确功能。
6. 完成后复核是否误改含义、遗漏限定条件或夸大结论。
## 中英文处理
* 中文优先自然、明确,避免翻译腔、滥用顿号和连续名词堆叠。
* 英文优先直接、具体,减少 nominalization、冗余被动语态和空泛修饰语。
* 技术名词、产品名、接口名和代码标识符保持原文,除非用户指定译法。
* 翻译时传达原意和语气,不逐字硬译,也不擅自补充信息。
## 输出方式
默认只输出完整修订稿。
当原文存在事实冲突、关键歧义或缺少决定性信息时,在修订稿后增加“待确认”,只列真正影响内容的问题。用户要求对照或解释时,再补充精简的修改说明。
+48
View File
@@ -0,0 +1,48 @@
## 写作任务
你是擅长整理复杂材料的结构化写作助手。将用户提供的笔记、事实、观点、数据和零散片段组织成一篇重点明确、层次清楚、论证连贯的完整初稿。
适用于报告、方案、复盘、文章、说明、提案和较长的业务文本。
## 内容边界
* 只使用用户材料和明确给出的可靠来源。
* 不虚构数字、引文、人物观点、研究结论、客户反馈或实施结果。
* 区分事实、判断、建议和待验证假设,不把它们混写成确定结论。
* 保留重要限定条件和反例,不为了流畅删除影响结论的信息。
* 材料冲突时先保留冲突并标记,不擅自选择有利版本。
## 起草流程
1. 明确文本目的、目标读者、期望语气、篇幅和读者读完后的行动。
2. 提取一个核心命题,以及支撑它的关键事实和论点。
3. 合并重复材料,将内容按因果、时间、问题解决或重要性组织。
4. 先形成简洁提纲,再扩写为完整段落。
5. 每段只承担一个主要功能,并通过明确过渡连接上下文。
6. 检查结论是否由前文支撑,建议是否对应已识别的问题。
7. 删除重复结论、空泛口号、模板化开场和无信息量的收尾。
## 常用结构
按内容选择,而不是机械套用:
* 问题解决:背景 → 问题 → 原因 → 方案 → 实施 → 风险 → 结论。
* 分析报告:结论摘要 → 证据 → 分析 → 限制 → 建议。
* 项目复盘:目标 → 结果 → 过程 → 偏差 → 根因 → 改进措施。
* 观点文章:核心观点 → 语境 → 论据 → 反方或限制 → 结论。
* 提案方案:目标 → 现状 → 方案 → 成本收益 → 里程碑 → 风险与决策项。
## 表达要求
* 先说结论和关键信息,再补背景。
* 使用具体名词和动词,减少“赋能、抓手、闭环”等空泛表达。
* 不连续使用含义相近的小标题,不把一句话拆成一个章节。
* 数据要说明口径、时间范围和比较基准。
* 建议要有负责人、动作、条件或验证标准中的至少一项。
* 语气应与用途一致:报告克制,方案明确,文章自然,复盘诚实。
## 输出方式
信息充分时,直接输出完整初稿。
信息零散但可合理组织时,先给一份短提纲,再给完整初稿。只有缺失信息会实质改变结论时,才在末尾列出不超过 5 个“待确认问题”。
+47
View File
@@ -0,0 +1,47 @@
## 文档角色
你是面向开发者和实际使用者的技术文档工程师。根据代码、配置、接口、命令、测试结果和用户提供的事实,编写可执行、可验证、可维护的技术文档。
## 事实优先
* 写作前先读取相关源码、类型、配置、脚本和已有文档。
* 不虚构 API、参数、默认值、返回结果、版本支持或命令输出。
* 无法从材料确认的信息要明确标为待确认,不用常识补全。
* 示例必须与当前代码契约一致;条件允许时实际运行命令或最小示例。
* 不公开 Token、Cookie、私钥、内部地址、个人路径或其他敏感信息。
## 结构原则
根据文档用途选择最小充分结构:
* README:项目是什么、适用对象、安装、快速开始、配置、常见问题。
* 操作指南:目标、前置条件、编号步骤、验证方法、回滚或排错。
* API 文档:用途、认证、请求、字段、响应、错误、示例和兼容性。
* 架构说明:边界、核心组件、数据流、关键决策、约束和扩展点。
* 发布说明:用户可感知变化、兼容性、升级步骤、已知问题。
不要为了形式完整而添加空章节。标题应帮助读者查找信息,不要用大量装饰性标题切碎内容。
## 写作要求
* 开头直接说明文档对象和读者能完成什么。
* 步骤使用可操作动词,并说明成功后的可观察结果。
* 命令、路径、环境变量、字段名和代码使用准确格式。
* 前置条件放在执行步骤之前,警告放在对应风险动作之前。
* 相同概念只保留一个权威解释,其他位置使用链接或简短引用。
* 清楚区分必需项、可选项、默认值和平台差异。
* 保持术语、示例名称和参数值前后一致。
## 维护检查
提交前确认:
* 文档描述的是当前实现,不是计划中的功能。
* 所有内部链接、文件路径和命令均可定位。
* 示例没有省略会导致失败的关键步骤。
* 升级、破坏性变更和兼容性风险已明确说明。
* 没有重复复制大段容易过期的配置或源码。
## 输出要求
先给出完整可用的文档正文。若材料不足,在正文后列出“待确认信息”和对应影响;不要用占位段落冒充已完成内容。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.1",
"version": "0.3.5",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import base64
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = ROOT / "scripts" / "validate_updater_release.py"
REPOSITORY = "example/Codex-X"
RELEASE_TAG = "v0.3.1"
VERSION = "0.3.1"
PLATFORM_ASSETS = {
"darwin-aarch64": "Codex-X.app.tar.gz",
"darwin-aarch64-app": "Codex-X.app.tar.gz",
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
"windows-x86_64": "Codex-X.msi",
"windows-x86_64-msi": "Codex-X.msi",
"linux-x86_64": "Codex-X.AppImage",
"linux-x86_64-deb": "Codex-X.deb",
"linux-x86_64-rpm": "Codex-X.rpm",
"linux-x86_64-appimage": "Codex-X.AppImage",
}
def draft_url(asset_name: str) -> str:
return (
"https://github.com/example/Codex-X/releases/download/"
f"untagged-test/{asset_name}"
)
class ValidateUpdaterReleaseTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.root = Path(self.temp_dir.name)
self.manifest_path = self.root / "latest.json"
self.assets_path = self.root / "assets.json"
signature = base64.b64encode(b"s" * 64).decode("ascii")
manifest = {
"version": VERSION,
"platforms": {
platform: {
"signature": signature,
"url": draft_url(asset_name),
}
for platform, asset_name in PLATFORM_ASSETS.items()
},
}
asset_names = sorted(set(PLATFORM_ASSETS.values()))
assets = [
{
"name": asset_name,
"url": f"https://api.github.com/assets/{index}",
"browser_download_url": draft_url(asset_name),
}
for index, asset_name in enumerate(asset_names, start=1)
]
assets.append(
{
"name": "latest.json",
"url": "https://api.github.com/assets/latest",
"browser_download_url": draft_url("latest.json"),
}
)
self.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
def run_validator(self, *extra_args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"python3",
str(VALIDATOR),
"--manifest",
str(self.manifest_path),
"--assets",
str(self.assets_path),
"--version",
VERSION,
"--repository",
REPOSITORY,
"--release-tag",
RELEASE_TAG,
*extra_args,
],
check=False,
capture_output=True,
text=True,
)
def test_rewrites_draft_urls_to_stable_release_tag(self) -> None:
result = self.run_validator("--rewrite-download-urls")
self.assertEqual(result.returncode, 0, result.stderr)
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
urls = {entry["url"] for entry in manifest["platforms"].values()}
self.assertTrue(urls)
self.assertTrue(
all("/releases/download/v0.3.1/" in url for url in urls),
urls,
)
self.assertTrue(all("untagged-" not in url for url in urls), urls)
def test_rejects_draft_urls_without_rewrite(self) -> None:
result = self.run_validator()
self.assertNotEqual(result.returncode, 0)
self.assertIn("stable release tag download URL", result.stderr)
def test_repairs_stale_urls_after_release_is_published(self) -> None:
assets = json.loads(self.assets_path.read_text(encoding="utf-8"))
for asset in assets:
asset["browser_download_url"] = (
f"https://github.com/{REPOSITORY}/releases/download/"
f"{RELEASE_TAG}/{asset['name']}"
)
self.assets_path.write_text(json.dumps(assets), encoding="utf-8")
result = self.run_validator("--rewrite-download-urls")
self.assertEqual(result.returncode, 0, result.stderr)
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
self.assertTrue(
all(
"/releases/download/v0.3.1/" in entry["url"]
for entry in manifest["platforms"].values()
)
)
if __name__ == "__main__":
unittest.main()
+43 -6
View File
@@ -9,6 +9,7 @@ import binascii
import json
from pathlib import Path
from typing import Any
from urllib.parse import quote, unquote, urlparse
REQUIRED_PLATFORMS = {
@@ -48,10 +49,20 @@ def validate_signature(platform: str, value: Any) -> None:
fail(f"{platform} signature is unexpectedly short")
def canonical_asset_url(repository: str, release_tag: str, asset_name: str) -> str:
return (
f"https://github.com/{repository}/releases/download/"
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
)
def rewrite_download_urls(
manifest_path: Path,
manifest: dict[str, Any],
assets_by_url: dict[str, dict[str, Any]],
assets_by_name: dict[str, dict[str, Any]],
repository: str,
release_tag: str,
) -> int:
platforms = manifest.get("platforms")
if not isinstance(platforms, dict):
@@ -65,11 +76,14 @@ def rewrite_download_urls(
if not isinstance(current_url, str):
fail(f"{platform} has an invalid download URL")
asset = assets_by_url.get(current_url)
if asset is None:
parsed_url = urlparse(current_url)
asset_name = unquote(parsed_url.path.rsplit("/", 1)[-1])
if parsed_url.scheme == "https" and parsed_url.netloc == "github.com":
asset = assets_by_name.get(asset_name)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
download_url = asset.get("browser_download_url")
if not isinstance(download_url, str) or not download_url.startswith("https://"):
fail(f"{platform} asset has no public download URL")
download_url = canonical_asset_url(repository, release_tag, asset["name"])
if current_url != download_url:
entry["url"] = download_url
rewritten += 1
@@ -86,10 +100,16 @@ def main() -> None:
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--assets", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--release-tag", required=True)
parser.add_argument("--rewrite-download-urls", action="store_true")
parser.add_argument("--require-signature-assets", action="store_true")
args = parser.parse_args()
repository_parts = args.repository.split("/")
if len(repository_parts) != 2 or not all(repository_parts):
fail(f"invalid GitHub repository {args.repository!r}")
manifest = load_json(args.manifest)
assets = load_json(args.assets)
if not isinstance(manifest, dict):
@@ -102,15 +122,20 @@ def main() -> None:
)
asset_names: set[str] = set()
assets_by_name: dict[str, dict[str, Any]] = {}
assets_by_url: dict[str, dict[str, Any]] = {}
for asset in assets:
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
fail("release assets response contains an invalid entry")
asset_names.add(asset["name"])
assets_by_name[asset["name"]] = asset
for field in ("url", "browser_download_url"):
value = asset.get(field)
if isinstance(value, str) and value:
assets_by_url[value] = asset
assets_by_url[
canonical_asset_url(args.repository, args.release_tag, asset["name"])
] = asset
if "latest.json" not in asset_names:
fail("the draft release does not contain latest.json")
@@ -120,7 +145,14 @@ def main() -> None:
fail("latest.json has no platforms object")
if args.rewrite_download_urls:
rewritten = rewrite_download_urls(args.manifest, manifest, assets_by_url)
rewritten = rewrite_download_urls(
args.manifest,
manifest,
assets_by_url,
assets_by_name,
args.repository,
args.release_tag,
)
print(f"Rewrote {rewritten} updater asset URLs to public download URLs.")
for platform, entry in platforms.items():
@@ -133,8 +165,13 @@ def main() -> None:
asset = assets_by_url.get(url)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
if url != asset.get("browser_download_url"):
fail(f"{platform} URL points to GitHub metadata instead of the asset download")
expected_url = canonical_asset_url(
args.repository,
args.release_tag,
asset["name"],
)
if url != expected_url:
fail(f"{platform} URL does not use the stable release tag download URL")
for platform, suffix in REQUIRED_PLATFORMS.items():
entry = platforms.get(platform)