Compare commits

..
3 Commits
Author SHA1 Message Date
yyyyyynnn 57075256fb Add session manager and polish updates 2026-07-04 13:28:45 +08:00
yyyyyynnn 529af6d796 Use changelog entries as release notes 2026-07-04 13:02:30 +08:00
yyyyyynnn 1f1100225e Simplify update page and releases link 2026-07-04 13:00:35 +08:00
10 changed files with 1108 additions and 92 deletions
+26 -10
View File
@@ -61,6 +61,31 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare release notes
id: release_notes
shell: bash
run: |
python3 - <<'PY'
from pathlib import Path
import os
import re
tag = os.environ["GITHUB_REF_NAME"]
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
match = re.search(
rf"^## \[{re.escape(tag)}\].*?\n(.*?)(?=^## \[|\Z)",
text,
flags=re.S | re.M,
)
body = match.group(1).strip() if match else ""
if not body:
body = "Codex-X desktop release."
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write("body<<EOF\n")
fh.write(body + "\n")
fh.write("EOF\n")
PY
- name: Build and upload Tauri bundles
uses: tauri-apps/tauri-action@v0
env:
@@ -69,16 +94,7 @@ jobs:
projectPath: apps/desktop
tagName: ${{ github.ref_name }}
releaseName: Codex-X ${{ github.ref_name }}
releaseBody: |
Codex-X desktop release.
See `CHANGELOG.md` for highlights.
- macOS: `.dmg`
- Windows: `.msi`
- Linux: `.deb` / `.rpm`
GitHub 会自动附带 Source code (zip/tar.gz)。
releaseBody: ${{ steps.release_notes.outputs.body }}
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}
+14
View File
@@ -2,6 +2,20 @@
All notable changes to Codex-X will be documented here.
## [v0.2.8] - 2026-07-04
- 更新页进一步产品化:去掉资源/仓库调试信息,将“有更新”改为更明显的绿色标签。
- 概览页顶部新增轻量“发现新版本”提示条,可直接打开 Releases 页面。
- 新增【会话管理】页面:检查 Codex 本地 sessions / archived_sessions 与 SQLite threads 是否和当前 Provider 同步。
- 新增一键 Provider Sync / 修复历史会话:写入前备份到 `~/.codex/backups_state/provider-sync/`,并保留最近 5 份备份。
## [v0.2.7] - 2026-07-04
- 简化更新检查页展示。
- “打开下载页”改为打开 GitHub Releases 页面。
- 更新弹窗保持简洁,仅提示版本差异。
- 首次启动自动检查更新并弹窗提醒。
## [v0.2.5] - 2026-07-04
- Windows 版双击启动不再额外弹出终端窗口。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.6",
"version": "0.2.8",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -337,7 +337,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.3"
version = "0.2.8"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.2.6"
version = "0.2.8"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.6",
"version": "0.2.8",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+139 -12
View File
@@ -6,6 +6,7 @@ import {
Code2,
Download,
ExternalLink,
AlertCircle,
FileCode2,
Globe2,
History,
@@ -29,7 +30,7 @@ import "./styles.css";
type Lang = "zh" | "en";
type ProviderMode = "list" | "form" | "official";
type InstructionMode = "list" | "form";
type Tab = "dashboard" | "provider" | "instruction" | "toml" | "settings" | "about";
type Tab = "dashboard" | "provider" | "sessions" | "instruction" | "toml" | "settings" | "about";
type InstructionTemplate = {
id: string;
@@ -130,6 +131,29 @@ type ReleaseInfo = {
hasUpdate?: boolean;
};
type SessionSyncStatus = {
codexDir: string;
targetProvider: string;
rolloutFiles: number;
sessionMetaCount: number;
mismatchedRollouts: number;
mismatchedSessionMeta: number;
sqliteDbs: number;
sqliteThreads: number;
mismatchedThreads: number;
needsSync: boolean;
backupDir?: string | null;
warnings: string[];
};
type SessionSyncResult = {
status: SessionSyncStatus;
updatedRollouts: number;
updatedThreads: number;
backupDir: string;
};
const INSTRUCTION_RELATIVE_UI = "./gpt5.5-unrestricted.md";
const LANG_KEY = "codexx.lang";
const FALLBACK_GITHUB_REPO = "yynxxxxx/Codex-X";
@@ -187,6 +211,7 @@ const dict = {
nav: {
dashboard: "概览",
provider: "供应商",
sessions: "会话管理",
instruction: "指令提示词",
toml: "TOML",
settings: "设置",
@@ -293,6 +318,7 @@ const dict = {
nav: {
dashboard: "Overview",
provider: "Provider",
sessions: "Sessions",
instruction: "Prompt",
toml: "TOML",
settings: "Settings",
@@ -661,6 +687,7 @@ function App() {
const [aboutInfo, setAboutInfo] = React.useState<AboutInfo | null>(null);
const [releaseInfo, setReleaseInfo] = React.useState<ReleaseInfo>({ status: "idle" });
const [updatePromptOpen, setUpdatePromptOpen] = React.useState(false);
const [sessionStatus, setSessionStatus] = React.useState<SessionSyncStatus | null>(null);
const [state, setState] = React.useState<CodexState | null>(null);
const [backups, setBackups] = React.useState<BackupEntry[]>([]);
const [configDir, setConfigDir] = React.useState("");
@@ -676,6 +703,13 @@ function App() {
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm, state), [providerForm, state]);
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
const currentInstructionId = instructionIdFromPath(state?.instructionFile);
const releaseStatusLabel = React.useMemo(() => {
if (releaseInfo.status === "checking") return lang === "zh" ? "检查中" : "Checking";
if (releaseInfo.status === "error") return lang === "zh" ? "失败" : "Failed";
if (releaseInfo.hasUpdate) return lang === "zh" ? "有更新" : "Update found";
if (releaseInfo.status === "ok") return lang === "zh" ? "已是最新" : "Up to date";
return lang === "zh" ? "未检查" : "Idle";
}, [lang, releaseInfo.hasUpdate, releaseInfo.status]);
React.useEffect(() => {
localStorage.setItem(LANG_KEY, lang);
@@ -761,14 +795,16 @@ function App() {
const providerList = await invoke<SavedProvider[]>("list_saved_providers");
const promptList = await invoke<SavedPrompt[]>("list_saved_prompts");
const about = await invoke<AboutInfo>("get_about_info", { configDir: configDir || null });
return { next, backupList, providerList, promptList, about };
const sessions = await invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || null, targetProvider: null });
return { next, backupList, providerList, promptList, about, sessions };
},
({ next, backupList, providerList, promptList, about }) => {
({ next, backupList, providerList, promptList, about, sessions }) => {
setState(next);
setBackups(backupList);
setSavedProviders(providerList);
setSavedPrompts(promptList);
setAboutInfo(about);
setSessionStatus(sessions);
if (!configDir) setConfigDir(next.codexDir);
},
);
@@ -964,6 +1000,7 @@ function App() {
const checkForUpdates = React.useCallback(async ({ quiet = false }: { quiet?: boolean } = {}) => {
const repo = aboutInfo?.githubRepo || FALLBACK_GITHUB_REPO;
const appVersion = aboutInfo?.appVersion || "0.0.0";
const releasesUrl = `https://github.com/${repo}/releases/`;
setReleaseInfo({ status: "checking" });
try {
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
@@ -985,7 +1022,7 @@ function App() {
setReleaseInfo({
status: "ok",
latestVersion,
htmlUrl: asset?.browser_download_url || release.html_url,
htmlUrl: releasesUrl,
assetName: asset?.name,
body: release.body || "",
hasUpdate,
@@ -997,7 +1034,7 @@ function App() {
} catch (e) {
setReleaseInfo({
status: "error",
message: quiet ? (lang === "zh" ? "自动检查失败" : "Auto check failed") : (lang === "zh" ? `检查失败:${String(e)}` : `Check failed: ${String(e)}`),
message: quiet ? (lang === "zh" ? "自动检查失败" : "Auto check failed") : (lang === "zh" ? "检查失败" : "Check failed"),
});
}
}, [aboutInfo?.githubRepo, aboutInfo?.appVersion, lang]);
@@ -1094,9 +1131,32 @@ function App() {
);
};
const checkSessions = () =>
call(
() => invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || null, targetProvider: null }),
(status) => {
setSessionStatus(status);
setToast(status.needsSync
? (lang === "zh" ? `发现 ${status.mismatchedSessionMeta + status.mismatchedThreads} 项需要同步` : "Session sync needed")
: (lang === "zh" ? "会话已同步" : "Sessions are in sync"));
},
);
const syncSessions = () =>
call(
() => invoke<SessionSyncResult>("sync_sessions_provider", { configDir: configDir || null, targetProvider: null }),
(result) => {
setSessionStatus(result.status);
setToast(lang === "zh"
? `已修复 ${result.updatedRollouts} 个会话文件、${result.updatedThreads} 条 SQLite 记录`
: `Updated ${result.updatedRollouts} rollout file(s), ${result.updatedThreads} SQLite row(s)`);
},
);
const navItems: Array<[Tab, string, React.ReactNode]> = [
["dashboard", t.nav.dashboard, <Layers3 size={18} />],
["provider", t.nav.provider, <Zap size={18} />],
["sessions", t.nav.sessions, <History size={18} />],
["instruction", t.nav.instruction, <Sparkles size={18} />],
["toml", t.nav.toml, <FileCode2 size={18} />],
["settings", t.nav.settings, <Settings size={18} />],
@@ -1169,7 +1229,6 @@ function App() {
<div className="about-kv compact">
<div><span>{lang === "zh" ? "当前版本" : "Current"}</span><strong>{aboutInfo?.appVersion || "-"}</strong></div>
<div><span>{lang === "zh" ? "最新版本" : "Latest"}</span><strong>{releaseInfo.latestVersion || "-"}</strong></div>
<div><span>{lang === "zh" ? "资源" : "Asset"}</span><code>{releaseInfo.assetName || "-"}</code></div>
</div>
</div>
<div className="update-actions">
@@ -1195,6 +1254,19 @@ function App() {
) : (
<>
{tab === "dashboard" && (
<>
{releaseInfo.status === "ok" && releaseInfo.hasUpdate && (
<div className="update-strip glass">
<div>
<span className="update-dot" />
<strong>{lang === "zh" ? "发现新版本" : "New version found"}</strong>
<p>{lang === "zh" ? `Codex-X ${releaseInfo.latestVersion || ""} 已发布` : `Codex-X ${releaseInfo.latestVersion || ""} is available`}</p>
</div>
<button className="secondary-btn small" onClick={() => releaseInfo.htmlUrl && void invoke("open_url", { url: releaseInfo.htmlUrl })}>
{lang === "zh" ? "查看更新" : "View"}
</button>
</div>
)}
<div className="grid dashboard-grid">
<StatCard icon={<TerminalSquare size={20} />} label={t.dashboard.config} value={state.configExists ? t.dashboard.found : t.dashboard.missing} ok={state.configExists} />
<StatCard icon={<Code2 size={20} />} label={t.dashboard.provider} value={currentProvider?.name || state.modelProvider || t.dashboard.officialDefault} ok={Boolean(state.modelProvider)} />
@@ -1227,6 +1299,7 @@ function App() {
</div>
</section>
</div>
</>
)}
{tab === "provider" && (
@@ -1389,6 +1462,63 @@ function App() {
</section>
)}
{tab === "sessions" && (
<section className="panel glass sessions-panel">
<div className="panel-head provider-title-row">
<div>
<p className="eyebrow">Provider Sync</p>
<h3>{lang === "zh" ? "会话管理" : "Session management"}</h3>
<p className="muted-desc">
{lang === "zh"
? "检查并修复 Codex 本地历史会话的 Provider 元数据,让切换供应商后旧 thread 仍能被原生 Codex 识别、打开和续聊。"
: "Check and repair local Codex session provider metadata so old threads stay visible and resumable after provider switching."}
</p>
</div>
<div className="provider-title-actions">
<button className="secondary-btn add-provider-btn" onClick={checkSessions} disabled={loading}>
<RefreshCw size={18} className={cx(loading && "spin")} /> {lang === "zh" ? "检查会话" : "Check"}
</button>
<button className="primary-btn add-provider-btn" onClick={syncSessions} disabled={loading || !sessionStatus?.needsSync}>
<Zap size={18} /> {lang === "zh" ? "同步 / 修复" : "Sync / repair"}
</button>
</div>
</div>
<div className={cx("session-status-card", sessionStatus?.needsSync ? "needs-sync" : "synced")}>
<div className="session-status-icon">
{sessionStatus?.needsSync ? <AlertCircle size={24} /> : <CheckCircle2 size={24} />}
</div>
<div>
<strong>{sessionStatus?.needsSync ? (lang === "zh" ? "发现未同步会话" : "Unsynced sessions found") : (lang === "zh" ? "会话已同步" : "Sessions are in sync")}</strong>
<p>{lang === "zh"
? `目标 Provider:${sessionStatus?.targetProvider || state.modelProvider || "openai"}`
: `Target provider: ${sessionStatus?.targetProvider || state.modelProvider || "openai"}`}</p>
</div>
{sessionStatus?.needsSync && <StatusPill active label={lang === "zh" ? "需要修复" : "Needs repair"} />}
</div>
<div className="session-stat-grid">
<StatCard icon={<FileCode2 size={20} />} label={lang === "zh" ? "rollout 文件" : "Rollout files"} value={sessionStatus?.rolloutFiles ?? "-"} ok />
<StatCard icon={<Sparkles size={20} />} label={lang === "zh" ? "session_meta" : "session_meta"} value={sessionStatus?.sessionMetaCount ?? "-"} ok />
<StatCard icon={<AlertCircle size={20} />} label={lang === "zh" ? "未同步 JSONL" : "Unsynced JSONL"} value={sessionStatus?.mismatchedRollouts ?? "-"} ok={!sessionStatus?.mismatchedRollouts} />
<StatCard icon={<Layers3 size={20} />} label={lang === "zh" ? "SQLite threads" : "SQLite threads"} value={sessionStatus?.sqliteThreads ?? "-"} ok />
<StatCard icon={<AlertCircle size={20} />} label={lang === "zh" ? "未同步记录" : "Unsynced rows"} value={sessionStatus?.mismatchedThreads ?? "-"} ok={!sessionStatus?.mismatchedThreads} />
<StatCard icon={<Code2 size={20} />} label={lang === "zh" ? "SQLite 数据库" : "SQLite DBs"} value={sessionStatus?.sqliteDbs ?? "-"} ok />
</div>
<div className="session-info-list">
<div><span>CODEX_HOME</span><code>{sessionStatus?.codexDir || state.codexDir}</code></div>
<div><span>{lang === "zh" ? "备份位置" : "Backup"}</span><code>{sessionStatus?.backupDir || `${state.codexDir}/backups_state/provider-sync`}</code></div>
</div>
{sessionStatus?.warnings?.length ? (
<div className="session-warning-list">
{sessionStatus.warnings.map((item, index) => <p key={index}><AlertCircle size={15} /> {item}</p>)}
</div>
) : null}
</section>
)}
{tab === "instruction" && (
<section className="panel glass instruction-panel simple-instruction-panel">
{instructionMode === "list" ? (
@@ -1525,18 +1655,15 @@ function App() {
<section className="panel glass about-card">
<div className="panel-head compact">
<div><p className="eyebrow">GitHub Releases</p><h3>{lang === "zh" ? "更新检查" : "Update check"}</h3></div>
<StatusPill active={releaseInfo.status === "ok" && Boolean(releaseInfo.hasUpdate)} label={releaseInfo.status === "checking" ? (lang === "zh" ? "检查中" : "Checking") : releaseInfo.message || (lang === "zh" ? "未检查" : "Idle")} />
<span className={cx("update-status-pill", releaseInfo.hasUpdate && "available")}>{releaseStatusLabel}</span>
</div>
<div className="about-kv">
<div><span>{lang === "zh" ? "状态" : "Status"}</span><strong>{releaseInfo.status}</strong></div>
<div><span>{lang === "zh" ? "状态" : "Status"}</span><strong>{releaseStatusLabel}</strong></div>
<div><span>{lang === "zh" ? "最新版本" : "Latest"}</span><strong>{releaseInfo.latestVersion || "-"}</strong></div>
<div><span>{lang === "zh" ? "资源" : "Asset"}</span><code>{releaseInfo.assetName || "-"}</code></div>
<div><span>{lang === "zh" ? "仓库" : "Repo"}</span><code>{aboutInfo?.githubRepo || FALLBACK_GITHUB_REPO}</code></div>
</div>
{releaseInfo.body && <pre className="release-notes">{releaseInfo.body}</pre>}
<div className="about-actions">
<button className="primary-btn" onClick={() => void checkForUpdates()} disabled={releaseInfo.status === "checking"}><RefreshCw size={16} className={cx(releaseInfo.status === "checking" && "spin")} /> {lang === "zh" ? "检查更新" : "Check updates"}</button>
<button className="secondary-btn" onClick={() => releaseInfo.htmlUrl && void invoke("open_url", { url: releaseInfo.htmlUrl })} disabled={!releaseInfo.htmlUrl}><Download size={16} /> {lang === "zh" ? "打开下载页" : "Open download"}</button>
<button className="secondary-btn" onClick={() => releaseInfo.htmlUrl && void invoke("open_url", { url: releaseInfo.htmlUrl })} disabled={!releaseInfo.htmlUrl}><Download size={16} /> {lang === "zh" ? "打开下载页" : "Open releases"}</button>
</div>
</section>
</section>
+160 -1
View File
@@ -235,7 +235,7 @@ input, select, textarea {
.update-body { display: grid; gap: 14px; color: #dfe7f8; }
.update-body p { margin: 0; line-height: 1.6; }
.about-kv.compact { grid-template-columns: 1fr 1fr 1fr; }
.about-kv.compact { grid-template-columns: 1fr 1fr; }
.update-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -1383,3 +1383,162 @@ html, body, #root {
background: rgba(255,255,255,.035);
border-radius: 999px;
}
/* Productized update surfaces. */
.update-status-pill {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 7px 12px;
border-radius: 999px;
color: #aab7d1;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.10);
font-size: 12px;
font-weight: 900;
letter-spacing: .01em;
}
.update-status-pill.available {
color: #071713;
background: linear-gradient(135deg, #2ff5b5, #9cffd8);
border-color: rgba(47, 245, 181, .75);
box-shadow: 0 10px 26px rgba(47, 245, 181, .18), inset 0 1px 0 rgba(255,255,255,.35);
}
.update-strip {
grid-column: 1 / -1;
min-height: 58px;
border-radius: 20px;
padding: 12px 14px 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
background: linear-gradient(135deg, rgba(31, 242, 178, .16), rgba(18, 211, 255, .08));
border-color: rgba(31, 242, 178, .26);
}
.update-strip > div {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.update-strip strong {
color: #eafff7;
white-space: nowrap;
}
.update-strip p {
margin: 0;
color: #9edcc8;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.update-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: #2ff5b5;
box-shadow: 0 0 0 6px rgba(47, 245, 181, .12), 0 0 20px rgba(47, 245, 181, .72);
flex: 0 0 auto;
}
/* Session management page. */
.sessions-panel {
min-height: calc(100vh - 36px);
display: flex;
flex-direction: column;
gap: 16px;
}
.session-status-card {
display: grid;
grid-template-columns: 52px 1fr auto;
gap: 14px;
align-items: center;
padding: 16px 18px;
border-radius: 22px;
background: rgba(7, 15, 31, .52);
border: 1px solid rgba(255,255,255,.10);
}
.session-status-card.synced {
border-color: rgba(31, 242, 178, .22);
background: linear-gradient(135deg, rgba(31, 242, 178, .10), rgba(7, 15, 31, .52));
}
.session-status-card.needs-sync {
border-color: rgba(47, 245, 181, .38);
background: linear-gradient(135deg, rgba(47, 245, 181, .14), rgba(18, 211, 255, .07));
}
.session-status-icon {
width: 50px;
height: 50px;
display: grid;
place-items: center;
border-radius: 17px;
color: #2ff5b5;
background: rgba(31, 242, 178, .12);
border: 1px solid rgba(31, 242, 178, .20);
}
.session-status-card strong {
display: block;
color: #f4f8ff;
margin-bottom: 5px;
font-size: 17px;
}
.session-status-card p {
margin: 0;
color: #9aa9c4;
font-size: 13px;
}
.session-stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.session-info-list {
display: grid;
gap: 10px;
padding: 16px;
border-radius: 22px;
background: rgba(4,10,24,.34);
border: 1px solid rgba(255,255,255,.08);
}
.session-info-list div {
display: grid;
grid-template-columns: 130px 1fr;
gap: 12px;
align-items: center;
}
.session-info-list span {
color: #91a0bb;
font-size: 13px;
font-weight: 850;
}
.session-warning-list {
display: grid;
gap: 8px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 176, 32, .08);
border: 1px solid rgba(255, 176, 32, .18);
}
.session-warning-list p {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
color: #ffd9a1;
font-size: 13px;
line-height: 1.55;
}
@media (max-width: 1320px) {
.session-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.session-status-card {
grid-template-columns: 52px 1fr;
}
.session-status-card .pill {
grid-column: 2;
justify-self: start;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.2.6",
"version": "0.2.8",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",