mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user