feat(codex): add opt-in migration and ledger-based restore for unified session history

- Enable dialog gains a checkbox (default off) to migrate existing
  official sessions from the built-in "openai" bucket into the shared
  "custom" bucket, with per-generation backups; failed migrations retry
  at startup
- Disable dialog offers a precise restore driven by the backup ledger:
  only sessions recorded as "openai" in backups are flipped back, and
  sessions created while the toggle was on are never touched
- Completion marker and backup generations are bound to the canonical
  Codex config dir; migrate/restore serialize on an op lock and the
  marker is written conditionally inside the settings write lock
- save_settings rolls back the toggle and fails the save when the live
  rewrite fails; migration additionally requires the live config to
  actually route to the shared bucket (skips with live_not_unified so
  refused injection or proxy takeover can't split history)
- Restore refuses to run while the toggle is (re-)enabled and reports
  nothing_to_restore instead of a zero-count success; local migration
  markers are now backend-owned in merge_settings_for_save so stale
  frontend payloads can't resurrect them
- Settings autosave reverts optimistic form state on failure so a
  failed toggle change can't be replayed by an unrelated save
- ConfirmDialog supports an optional checkbox; all four locales updated
This commit is contained in:
Jason
2026-06-12 23:35:01 +08:00
parent 948d762792
commit eab6bfd20c
15 changed files with 1369 additions and 43 deletions
+31 -2
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@@ -7,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -18,7 +20,10 @@ interface ConfirmDialogProps {
cancelText?: string;
variant?: "destructive" | "info";
zIndex?: "base" | "nested" | "alert" | "top";
onConfirm: () => void;
/** 可选勾选项:提供 label 即显示,勾选状态经 onConfirm 参数回传 */
checkboxLabel?: string;
checkboxDefaultChecked?: boolean;
onConfirm: (checkboxChecked: boolean) => void;
onCancel: () => void;
}
@@ -30,10 +35,21 @@ export function ConfirmDialog({
cancelText,
variant = "destructive",
zIndex = "alert",
checkboxLabel,
checkboxDefaultChecked = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const { t } = useTranslation();
const [checkboxChecked, setCheckboxChecked] = useState(
checkboxDefaultChecked,
);
useEffect(() => {
if (isOpen) {
setCheckboxChecked(checkboxDefaultChecked);
}
}, [isOpen, checkboxDefaultChecked]);
const IconComponent = variant === "info" ? Info : AlertTriangle;
const iconClass =
@@ -58,13 +74,26 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
{checkboxLabel ? (
<label className="flex cursor-pointer select-none items-start gap-2 px-6 pt-3">
<Checkbox
checked={checkboxChecked}
onCheckedChange={(value) => setCheckboxChecked(value === true)}
className="mt-0.5"
/>
<span className="text-sm leading-relaxed">{checkboxLabel}</span>
</label>
) : null}
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
<Button
variant={variant === "info" ? "default" : "destructive"}
onClick={onConfirm}
onClick={() =>
// 未渲染勾选框时不得回传 defaultChecked 残留值
onConfirm(checkboxLabel ? checkboxChecked : false)
}
>
{confirmText || t("common.confirm")}
</Button>
+100 -3
View File
@@ -1,11 +1,18 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { History, KeyRound } from "lucide-react";
import { toast } from "sonner";
import type { SettingsFormState } from "@/hooks/useSettings";
import { ToggleRow } from "@/components/ui/toggle-row";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
interface CodexAuthSettingsProps {
settings: SettingsFormState;
onChange: (updates: Partial<SettingsFormState>) => void;
/** 返回 false(或 resolve 为 false)表示保存失败;其余返回值视为成功 */
onChange: (
updates: Partial<SettingsFormState>,
) => void | boolean | Promise<void | boolean>;
}
export function CodexAuthSettings({
@@ -13,6 +20,73 @@ export function CodexAuthSettings({
onChange,
}: CodexAuthSettingsProps) {
const { t } = useTranslation();
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
const [hasUnifyBackup, setHasUnifyBackup] = useState(false);
const handleUnifyHistoryChange = (checked: boolean) => {
if (checked) {
setShowEnableConfirm(true);
return;
}
// 先探测有无迁移备份,决定关闭弹窗是否提供"恢复备份"勾选
void settingsApi
.hasCodexUnifyHistoryBackup()
.catch(() => false)
.then((hasBackup) => {
setHasUnifyBackup(hasBackup);
setShowDisableConfirm(true);
});
};
const handleEnableConfirm = (migrateExisting: boolean) => {
setShowEnableConfirm(false);
void onChange({
unifyCodexSessionHistory: true,
unifyCodexMigrateExisting: migrateExisting,
});
};
// 备份探测可能落后于正在后台进行的迁移(刚勾选迁入就立刻关闭时,
// 备份尚未产出)。只要本轮勾选过"迁入既有会话",就必须提供恢复入口;
// 真正有没有账本交给后端 restore 的 skippedReason 判定。
const showRestoreOption =
hasUnifyBackup || (settings.unifyCodexMigrateExisting ?? false);
const handleDisableConfirm = async (restoreBackup: boolean) => {
setShowDisableConfirm(false);
const saved = await onChange({
unifyCodexSessionHistory: false,
unifyCodexMigrateExisting: false,
});
// 关闭保存失败时绝不还原:否则开关仍开着(live 仍统一路由),
// 已迁移会话却被翻回 openai 桶,历史被拆成两半。
if (saved === false) return;
// 不再以探测结果短路:还原命令会在迁移锁上排队,等到迁移落盘后
// 拿到完整账本;确实无账本时由 skippedReason 提示。
if (!restoreBackup) return;
try {
const result = await settingsApi.restoreCodexUnifiedHistory();
if (result.skippedReason) {
// unify_toggle_on:还原排队期间开关被重新开启,后端拒绝还原
toast.info(
result.skippedReason === "unify_toggle_on"
? t("settings.unifyCodexHistoryRestoreSkippedToggleOn")
: t("settings.unifyCodexHistoryRestoreNothing"),
);
return;
}
toast.success(
t("settings.unifyCodexHistoryRestoreCompleted", {
files: result.restoredJsonlFiles,
rows: result.restoredStateRows,
}),
);
} catch (error) {
console.error("Failed to restore codex unified history:", error);
toast.error(t("settings.unifyCodexHistoryRestoreFailed"));
}
};
return (
<section className="space-y-4">
@@ -36,9 +110,32 @@ export function CodexAuthSettings({
title={t("settings.unifyCodexSessionHistory")}
description={t("settings.unifyCodexSessionHistoryDescription")}
checked={settings.unifyCodexSessionHistory ?? false}
onCheckedChange={(value) =>
onChange({ unifyCodexSessionHistory: value })
onCheckedChange={handleUnifyHistoryChange}
/>
<ConfirmDialog
isOpen={showEnableConfirm}
title={t("confirm.unifyCodexHistory.title")}
message={t("confirm.unifyCodexHistory.message")}
checkboxLabel={t("confirm.unifyCodexHistory.migrateExisting")}
confirmText={t("confirm.unifyCodexHistory.confirm")}
onConfirm={handleEnableConfirm}
onCancel={() => setShowEnableConfirm(false)}
/>
<ConfirmDialog
isOpen={showDisableConfirm}
title={t("confirm.unifyCodexHistoryOff.title")}
message={t("confirm.unifyCodexHistoryOff.message")}
checkboxLabel={
showRestoreOption
? t("confirm.unifyCodexHistoryOff.restoreBackup")
: undefined
}
checkboxDefaultChecked
confirmText={t("confirm.unifyCodexHistoryOff.confirm")}
onConfirm={(restoreBackup) => void handleDisableConfirm(restoreBackup)}
onCancel={() => setShowDisableConfirm(false)}
/>
</section>
);
+1 -1
View File
@@ -22,7 +22,7 @@ import type { SettingsFormState } from "@/hooks/useSettings";
interface ProxyTabContentProps {
settings: SettingsFormState;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<void>;
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
}
export function ProxyTabContent({
+17 -2
View File
@@ -163,19 +163,34 @@ export function SettingsPage({
// 通用设置即时保存(无需手动点击)
// 使用 autoSaveSettings 避免误触发系统 API(开机自启、Claude 插件等)
// 返回保存是否成功:需要在保存成功后追加动作的调用方(如统一会话历史
// 关闭后的备份还原)据此短路,其余调用方可忽略返回值。
const handleAutoSave = useCallback(
async (updates: Partial<SettingsFormState>) => {
if (!settings) return;
async (updates: Partial<SettingsFormState>): Promise<boolean> => {
if (!settings) return false;
// 乐观更新前捕获旧值:autoSaveSettings 发送的是全量表单状态,后端按
// diff 触发副作用(如统一会话开关的 live 重写与历史迁移)。保存失败
// 不回滚的话,失败的变更会滞留在表单里,被之后任意一次无关保存原样
// 重放,绕过确认弹窗。
const previousValues = Object.fromEntries(
Object.keys(updates).map((key) => [
key,
settings[key as keyof SettingsFormState],
]),
) as Partial<SettingsFormState>;
updateSettings(updates);
try {
await autoSaveSettings(updates);
return true;
} catch (error) {
console.error("[SettingsPage] Failed to autosave settings", error);
updateSettings(previousValues);
toast.error(
t("settings.saveFailedGeneric", {
defaultValue: "保存失败,请重试",
}),
);
return false;
}
},
[autoSaveSettings, settings, t, updateSettings],
+17 -1
View File
@@ -279,6 +279,18 @@
"message": "Failover is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend configuring provider priorities in the failover queue first.",
"confirm": "I understand, enable"
},
"unifyCodexHistory": {
"title": "Unified Codex session history",
"message": "When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.\n\nYou can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).",
"migrateExisting": "Also migrate existing official session history",
"confirm": "I understand, enable"
},
"unifyCodexHistoryOff": {
"title": "Turn off unified session history",
"message": "After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.",
"restoreBackup": "Restore the official sessions migrated at enable time back to the official history (exact restore from backup)",
"confirm": "Turn off"
},
"usage": {
"title": "Configure Usage Query",
"message": "Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\n\nIf unsure how to configure, please consult your provider's documentation first.",
@@ -671,7 +683,11 @@
"preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers",
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.",
"unifyCodexSessionHistory": "Unified Codex session history",
"unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id, so future official sessions appear in the same history list as third-party sessions (existing sessions are not migrated). Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.",
"unifyCodexSessionHistoryDescription": "When enabled, the official subscription runs under the shared \"custom\" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.",
"unifyCodexHistoryRestoreCompleted": "Official session history restored from backup ({{files}} session files, {{rows}} index rows)",
"unifyCodexHistoryRestoreFailed": "Failed to restore official session history, please try again",
"unifyCodexHistoryRestoreNothing": "No restorable migration backup for the current Codex directory",
"unifyCodexHistoryRestoreSkippedToggleOn": "Unified session history was re-enabled; restore skipped",
"appVisibility": {
"title": "Homepage Display",
"description": "Choose which apps to show on the homepage",
+17 -1
View File
@@ -279,6 +279,18 @@
"message": "フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。",
"confirm": "理解しました、有効にする"
},
"unifyCodexHistory": {
"title": "Codex セッション履歴を統一",
"message": "オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。\n\n既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。",
"migrateExisting": "既存の公式セッション履歴も移行する",
"confirm": "理解しました、オンにする"
},
"unifyCodexHistoryOff": {
"title": "セッション履歴の統一をオフにする",
"message": "オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。",
"restoreBackup": "オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)",
"confirm": "オフにする"
},
"usage": {
"title": "使用量クエリの設定",
"message": "使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\n\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。",
@@ -671,7 +683,11 @@
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
"preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。",
"unifyCodexSessionHistory": "Codex セッション履歴を統一",
"unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、今後の公式セッションはサードパーティのセッション同じ履歴リストに表示されます既存セッション移行しません)。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。",
"unifyCodexSessionHistoryDescription": "オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッション同じ履歴リストに表示されます既存の公式セッション移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。",
"unifyCodexHistoryRestoreCompleted": "バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)",
"unifyCodexHistoryRestoreFailed": "公式セッション履歴の復元に失敗しました。もう一度お試しください",
"unifyCodexHistoryRestoreNothing": "現在の Codex ディレクトリに復元可能な移行バックアップはありません",
"unifyCodexHistoryRestoreSkippedToggleOn": "統一セッション履歴が再度有効化されたため、復元をスキップしました",
"appVisibility": {
"title": "ホームページ表示",
"description": "ホームページに表示するアプリを選択",
+17 -1
View File
@@ -279,6 +279,18 @@
"message": "故障轉移是一項進階功能,啟用前請確保您已了解其運作原理。\n\n建議先在故障轉移佇列中設定好供應商優先順序。",
"confirm": "我已了解,繼續啟用"
},
"unifyCodexHistory": {
"title": "統一 Codex 會話歷史",
"message": "開啟後,官方訂閱與第三方將共用同一個會話歷史清單。注意:跨供應商繼續舊會話時,可能因對方後端無法解密 encrypted_content 推理內容而失敗。\n\n可選擇同時把現有官方會話歷史遷入共享清單(遷移前自動備份到 ~/.cc-switch/backups,關閉開關時可選擇恢復)。",
"migrateExisting": "同時遷入現有官方會話歷史",
"confirm": "我已了解,繼續開啟"
},
"unifyCodexHistoryOff": {
"title": "關閉統一會話歷史",
"message": "關閉後,官方訂閱與第三方將恢復各自獨立的會話歷史清單。開啟期間產生的會話因無法區分來源,將留在第三方歷史中,官方訂閱將看不到它們。",
"restoreBackup": "把開啟時遷入的官方會話還原回官方歷史(按備份精確還原)",
"confirm": "關閉"
},
"usage": {
"title": "設定用量查詢",
"message": "用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\n\n如不確定如何設定,請先查閱供應商文件。",
@@ -671,7 +683,11 @@
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能",
"unifyCodexSessionHistory": "統一 Codex 會話歷史",
"unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,未來的官方會話與第三方會話出現在同一歷史清單中(不遷移既有會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗",
"unifyCodexSessionHistoryDescription": "開啟後,官方訂閱將以共享的 custom 供應商標識執行,官方與第三方會話出現在同一歷史清單中,並可選擇把現有官方會話一併遷入(遷移前自動備份)。關閉開關時可按備份恢復遷入的會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗",
"unifyCodexHistoryRestoreCompleted": "已按備份還原官方會話歷史({{files}} 個會話檔案、{{rows}} 條索引記錄)",
"unifyCodexHistoryRestoreFailed": "還原官方會話歷史失敗,請重試",
"unifyCodexHistoryRestoreNothing": "目前 Codex 目錄沒有可恢復的遷移備份",
"unifyCodexHistoryRestoreSkippedToggleOn": "統一會話歷史開關已重新開啟,已跳過還原",
"appVisibility": {
"title": "主頁面顯示",
"description": "選擇在主頁面顯示的應用程式",
+17 -1
View File
@@ -279,6 +279,18 @@
"message": "故障转移是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先在故障转移队列中配置好供应商优先级。",
"confirm": "我已了解,继续启用"
},
"unifyCodexHistory": {
"title": "统一 Codex 会话历史",
"message": "开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。\n\n可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。",
"migrateExisting": "同时迁入现有官方会话历史",
"confirm": "我已了解,继续开启"
},
"unifyCodexHistoryOff": {
"title": "关闭统一会话历史",
"message": "关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。",
"restoreBackup": "把开启时迁入的官方会话还原回官方历史(按备份精确还原)",
"confirm": "关闭"
},
"usage": {
"title": "配置用量查询",
"message": "用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\n\n如不确定如何配置,请先查阅供应商文档。",
@@ -671,7 +683,11 @@
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
"preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能",
"unifyCodexSessionHistory": "统一 Codex 会话历史",
"unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,未来的官方会话与第三方会话出现在同一历史列表中(不迁移既有会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败",
"unifyCodexSessionHistoryDescription": "开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败",
"unifyCodexHistoryRestoreCompleted": "已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)",
"unifyCodexHistoryRestoreFailed": "还原官方会话历史失败,请重试",
"unifyCodexHistoryRestoreNothing": "当前 Codex 目录没有可恢复的迁移备份",
"unifyCodexHistoryRestoreSkippedToggleOn": "统一会话历史开关已重新开启,已跳过还原",
"appVisibility": {
"title": "主页面显示",
"description": "选择在主页面显示的应用",
+17
View File
@@ -19,6 +19,13 @@ export interface WebDavTestResult {
message?: string;
}
export interface CodexUnifyHistoryRestoreResult {
restoredJsonlFiles: number;
restoredStateRows: number;
/** 还原被跳过的原因(如当前目录没有账本);存在时不应报成功 */
skippedReason?: string;
}
export interface WebDavSyncResult {
status: string;
}
@@ -32,6 +39,16 @@ export const settingsApi = {
return await invoke("save_settings", { settings });
},
/** 是否存在统一 Codex 会话历史的迁移备份(关闭弹窗据此显示"恢复备份"勾选) */
async hasCodexUnifyHistoryBackup(): Promise<boolean> {
return await invoke("has_codex_unify_history_backup");
},
/** 按迁移备份账本把当时迁入共享桶的官方会话还原回 openai 桶(幂等) */
async restoreCodexUnifiedHistory(): Promise<CodexUnifyHistoryRestoreResult> {
return await invoke("restore_codex_unified_history");
},
async restart(): Promise<boolean> {
return await invoke("restart_app");
},
+1 -1
View File
@@ -59,7 +59,7 @@ export const settingsSchema = z.object({
})
.optional(),
// 本机自动迁移状态(后端维护,前端保存设置时应透传
// 本机自动迁移状态(后端维护且保存时后端忽略前端值,仅供读取展示
localMigrations: z
.object({
codexThirdPartyHistoryProviderBucketV1: z
+2
View File
@@ -354,6 +354,8 @@ export interface Settings {
// Run official Codex under the shared "custom" provider id so future
// sessions share one resume-history bucket with third-party providers
unifyCodexSessionHistory?: boolean;
// User opted in (enable dialog checkbox) to migrate existing official sessions
unifyCodexMigrateExisting?: boolean;
// User has confirmed the failover toggle first-run notice
failoverConfirmed?: boolean;
// User has confirmed the first-run welcome notice