Files
CC-Switch/src/hooks/useSettingsForm.ts
T
Jason 948d762792 feat(codex): add unified session history toggle for official providers
Codex buckets resume history by the model_provider id recorded in each
session: official runs (no key, built-in "openai") and cc-switch
third-party runs (shared "custom") are mutually invisible in the resume
picker. Add an opt-in setting that runs official providers under the
shared "custom" id so future official sessions land in the same history
bucket as third-party ones. Forward-only by design: existing sessions
are not migrated.

When enabled, official live config.toml gets model_provider = "custom"
plus a [model_providers.custom] entry that mirrors the built-in openai
provider (requires_openai_auth routes auth to the ChatGPT login in
auth.json, name "OpenAI" keeps is_openai() feature gates, explicit
supports_websockets/wire_api restore built-in defaults). auth.json is
untouched.

Key invariants:
- Injection lives only in the live config: switch-away backfill strips
  the exact injected shape, so stored provider configs stay clean and
  turning the toggle off fully reverts on the next write.
- Toggle changes apply immediately via a takeover-aware reapply: when
  the proxy owns the live config (backup/placeholder present), only the
  live backup is updated, mirroring the provider-switch path.
- The takeover backup path runs the same injection so a takeover
  release restores a config that still carries the unified routing.
- Injection refuses to activate a foreign [model_providers.custom]
  table (e.g. stale entry with a third-party base_url) to avoid routing
  ChatGPT OAuth traffic to an unknown backend.

The toggle lives under Settings → Codex App Enhancements; the
description warns that resuming old sessions across providers may fail
because encrypted_content reasoning only decrypts on the backend that
created it (upstream treats cross-provider resume as unsupported).
2026-06-12 10:40:51 +08:00

212 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSettingsQuery } from "@/lib/query";
import type { Settings } from "@/types";
type Language = "zh" | "zh-TW" | "en" | "ja";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
};
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
const normalized = lang.toLowerCase().replace(/_/g, "-");
if (normalized === "zh") {
return "zh";
}
if (
normalized === "zh-tw" ||
normalized.startsWith("zh-hant") ||
normalized.startsWith("zh-hk") ||
normalized.startsWith("zh-mo")
) {
return "zh-TW";
}
if (normalized === "en" || normalized === "ja") {
return normalized;
}
if (normalized.startsWith("zh")) {
return "zh";
}
return "zh";
};
const isSupportedLanguage = (lang?: string | null): boolean => {
if (!lang) return false;
const normalized = lang.toLowerCase().replace(/_/g, "-");
return (
normalized === "en" || normalized === "ja" || normalized.startsWith("zh")
);
};
const sanitizeDir = (value?: string | null): string | undefined => {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
export interface UseSettingsFormResult {
settings: SettingsFormState | null;
isLoading: boolean;
initialLanguage: Language;
updateSettings: (updates: Partial<SettingsFormState>) => void;
resetSettings: (serverData: Settings | null) => void;
readPersistedLanguage: () => Language;
syncLanguage: (lang: Language) => void;
}
/**
* useSettingsForm - 表单状态管理
* 负责:
* - 表单数据状态
* - 表单字段更新
* - 语言同步
* - 表单重置
*/
export function useSettingsForm(): UseSettingsFormResult {
const { i18n } = useTranslation();
const { data, isLoading } = useSettingsQuery();
const [settingsState, setSettingsState] = useState<SettingsFormState | null>(
null,
);
const initialLanguageRef = useRef<Language>("zh");
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (isSupportedLanguage(stored)) {
return normalizeLanguage(stored);
}
}
return normalizeLanguage(i18n.language);
}, [i18n]);
const syncLanguage = useCallback(
(lang: Language) => {
const current = normalizeLanguage(i18n.language);
if (current !== lang) {
void i18n.changeLanguage(lang);
}
},
[i18n],
);
// 初始化设置数据
useEffect(() => {
if (!data) return;
const normalizedLanguage = normalizeLanguage(
data.language ?? readPersistedLanguage(),
);
const normalized: SettingsFormState = {
...data,
showInTray: data.showInTray ?? true,
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
useAppWindowControls: data.useAppWindowControls ?? false,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
silentStartup: data.silentStartup ?? false,
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
preserveCodexOfficialAuthOnSwitch:
data.preserveCodexOfficialAuthOnSwitch ?? false,
unifyCodexSessionHistory: data.unifyCodexSessionHistory ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
openclawConfigDir: sanitizeDir(data.openclawConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
initialLanguageRef.current = normalizedLanguage;
syncLanguage(normalizedLanguage);
}, [data, readPersistedLanguage, syncLanguage]);
const updateSettings = useCallback(
(updates: Partial<SettingsFormState>) => {
setSettingsState((prev) => {
const base =
prev ??
({
showInTray: true,
minimizeToTrayOnClose: true,
useAppWindowControls: false,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: false,
preserveCodexOfficialAuthOnSwitch: false,
unifyCodexSessionHistory: false,
language: readPersistedLanguage(),
} as SettingsFormState);
const next: SettingsFormState = {
...base,
...updates,
};
if (updates.language) {
const normalized = normalizeLanguage(updates.language);
next.language = normalized;
syncLanguage(normalized);
}
return next;
});
},
[readPersistedLanguage, syncLanguage],
);
const resetSettings = useCallback(
(serverData: Settings | null) => {
if (!serverData) return;
const normalizedLanguage = normalizeLanguage(
serverData.language ?? readPersistedLanguage(),
);
const normalized: SettingsFormState = {
...serverData,
showInTray: serverData.showInTray ?? true,
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
useAppWindowControls: serverData.useAppWindowControls ?? false,
enableClaudePluginIntegration:
serverData.enableClaudePluginIntegration ?? false,
silentStartup: serverData.silentStartup ?? false,
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
preserveCodexOfficialAuthOnSwitch:
serverData.preserveCodexOfficialAuthOnSwitch ?? false,
unifyCodexSessionHistory: serverData.unifyCodexSessionHistory ?? false,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
openclawConfigDir: sanitizeDir(serverData.openclawConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
syncLanguage(initialLanguageRef.current);
},
[readPersistedLanguage, syncLanguage],
);
return {
settings: settingsState,
isLoading,
initialLanguage: initialLanguageRef.current,
updateSettings,
resetSettings,
readPersistedLanguage,
syncLanguage,
};
}