diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 0000000..49a20cf --- /dev/null +++ b/docs/I18N.md @@ -0,0 +1,80 @@ +# Internationalization + +Available languages: Simplified Chinese (`zh-CN`), English (`en`), and Russian (`ru`). +The language menu also offers Follow system. Manual choices persist locally. + +User-owned text (account names, notes, session previews, paths, and skill contents) +must be wrapped in `translate="no"` or `data-i18n-ignore`. The runtime skips both +the text and attributes of those elements and their descendants. Do not place +static UI labels inside these protected containers. + +English translations reuse earlier public UI wording from +[InventiveSpark/codex-switcher](https://github.com/InventiveSpark/codex-switcher), +with additional translations for the current features. The English catalog is +checked against every Russian source key for coverage and placeholder counts. + +## 中文 + +### 工作方式 + +- 简体中文是源码语言,也是无法识别系统语言或没有对应翻译时的默认语言。 +- 应用启动时优先读取系统语言;Linux 上如果进程只有 `C/POSIX` 通用语言,则回退到 `/etc/default/locale`,然后在 `src/i18n/index.ts` 的 `appLocales` 中匹配语言前缀。 +- 如果用户没有手动选择语言,应用会根据系统语言自动选择已提供的翻译;未识别或未提供翻译时使用简体中文。顶栏菜单只显示可用语言,用户手动选择的语言保存在本机 `localStorage` 中,并覆盖后续自动检测。 +- `src/i18n/runtime.ts` 负责翻译 React 渲染出的文本、常用可访问性属性、浏览器对话框和通知。 +- Tauri 后端通过 `set_app_locale` 与前端使用相同语言;OAuth 页面、托盘菜单和系统通知放在 `src-tauri/src/i18n/` 中。 + +### 添加新语言 + +1. 复制 `src/i18n/ru.ts` 为新的语言文件,实现并导出一个 `UiLocale`。 +2. 在 `src/i18n/index.ts` 导入该文件,并向 `appLocales` 添加语言代码、系统语言前缀、本地语言名称、旗帜、翻译对象和 `contributors` 中的翻译贡献者。可以为每位贡献者添加可选的个人主页 URL。 +3. 如果该语言需要翻译后端可见文本,复制 `src-tauri/src/i18n/ru.rs`,实现 `BackendLocale`,然后在 `src-tauri/src/i18n/mod.rs` 的 `BACKEND_LOCALES` 中注册。 +4. 优先翻译完整句子。动态消息中的 `{}`、`{name}` 等占位符数量必须与中文源文本一致。 +5. 运行 `npm run check:i18n`、`npm run build` 和 `cargo check --manifest-path src-tauri/Cargo.toml`。 + +`contributors` 会在“关于”中自动生成翻译贡献者列表。该列表仅在当前语言不是源语言时显示。每个新的语言文件还必须翻译列表标题 `翻译` 和说明 `感谢帮助应用支持更多语言的贡献者`。语言的本地名称和贡献者姓名不会再次翻译。 + +不要直接把组件中的中文替换为另一种语言。中文必须继续作为可维护的源码和回退语言。 + +## English + +### How it works + +- Simplified Chinese is the source language and the fallback when the system locale is unknown or no translation is available. +- At startup, the app reads the system locale and matches its prefix against `appLocales` in `src/i18n/index.ts`. On Linux, a generic `C/POSIX` process locale falls back to `/etc/default/locale`. +- When the user has not chosen a language manually, the app detects the system locale and selects an available translation; unknown or unavailable locales fall back to Simplified Chinese. The header menu lists only available languages. A manual choice is stored in `localStorage` and disables later automatic switching. +- `src/i18n/runtime.ts` translates React-rendered text, common accessibility attributes, browser dialogs, and notifications. +- The frontend synchronizes the selected locale with Tauri through `set_app_locale`. OAuth pages, native tray items, and system notifications live in `src-tauri/src/i18n/`. + +### Adding a language + +1. Copy `src/i18n/ru.ts` to a new locale file and export a `UiLocale` implementation. +2. Import it in `src/i18n/index.ts`, then add its locale code, system-language prefixes, native name, flag, translation object, and translation contributors in `contributors` to `appLocales`. Each contributor may have an optional profile URL. +3. If native backend text must be translated, copy `src-tauri/src/i18n/ru.rs`, implement `BackendLocale`, and register it in `BACKEND_LOCALES` in `src-tauri/src/i18n/mod.rs`. +4. Prefer complete messages over fragments. Preserve the number of `{}` and named placeholders from the Chinese source text. +5. Run `npm run check:i18n`, `npm run build`, and `cargo check --manifest-path src-tauri/Cargo.toml`. + +The `contributors` entries automatically populate the translation credits in About. The credits are shown only when the active language differs from the source language. Every new locale file must also translate the credits heading `翻译` and description `感谢帮助应用支持更多语言的贡献者`. Native language names and contributor names are displayed as registered and are not translated again. + +Do not replace Chinese strings directly in components. Chinese must remain the maintainable source and fallback language. + +## Русский + +### Как это работает + +- Упрощённый китайский остаётся исходным языком и включается, если системная локаль не определена или для неё нет перевода. +- При запуске приложение читает системную локаль и сопоставляет её префикс с реестром `appLocales` в `src/i18n/index.ts`. В Linux при общей локали процесса `C/POSIX` используется `/etc/default/locale`. +- Если пользователь ещё не выбирал язык вручную, приложение определяет системную локаль и выбирает доступный перевод; при неизвестной локали или отсутствии перевода используется упрощённый китайский. В меню шапки отображаются только доступные языки. Ручной выбор сохраняется в `localStorage` и отключает дальнейшее автоматическое переключение. +- `src/i18n/runtime.ts` переводит текст, созданный React, основные атрибуты доступности, браузерные диалоги и уведомления. +- Frontend передаёт выбранную локаль Tauri-командой `set_app_locale`. OAuth-страницы, нативное tray-меню и системные уведомления находятся в `src-tauri/src/i18n/`. + +### Как добавить язык + +1. Скопируйте `src/i18n/ru.ts` в файл новой локали и экспортируйте реализацию `UiLocale`. +2. Импортируйте её в `src/i18n/index.ts`, затем добавьте в `appLocales` код локали, префиксы системного языка, самоназвание языка, флаг, объект перевода и авторов перевода в `contributors`. Для каждого автора можно указать необязательную ссылку на профиль. +3. Если нужен перевод видимых строк backend, скопируйте `src-tauri/src/i18n/ru.rs`, реализуйте `BackendLocale` и зарегистрируйте локаль в `BACKEND_LOCALES` из `src-tauri/src/i18n/mod.rs`. +4. Предпочитайте переводы целых сообщений фрагментам. Сохраняйте число `{}` и именованных подстановок из исходной китайской строки. +5. Выполните `npm run check:i18n`, `npm run build` и `cargo check --manifest-path src-tauri/Cargo.toml`. + +Записи `contributors` автоматически формируют список авторов переводов в разделе «О программе». Список показывается, только когда активный язык отличается от исходного. В файле каждой новой локали также нужно перевести заголовок `翻译` и описание `感谢帮助应用支持更多语言的贡献者`. Самоназвания языков и имена участников выводятся как зарегистрированы и повторно не переводятся. + +Не заменяйте китайские строки непосредственно в компонентах. Китайский должен оставаться поддерживаемым исходным и резервным языком. diff --git a/scripts/fixtures/i18n-data.tsx b/scripts/fixtures/i18n-data.tsx index f636769..2760623 100644 --- a/scripts/fixtures/i18n-data.tsx +++ b/scripts/fixtures/i18n-data.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { createRoot } from 'react-dom/client'; import { installAppLocale } from '../../src/i18n'; +import { LanguagePicker } from '../../src/components/LanguagePicker'; import '../../src/App.css'; installAppLocale('ru-RU'); @@ -8,6 +9,7 @@ installAppLocale('ru-RU'); function Fixture() { const [name, setName] = useState('生产账号A'); return
+

账号

复制 {name}

客户备注:测试账号。保持原文。

diff --git a/scripts/i18n-runtime.test.mjs b/scripts/i18n-runtime.test.mjs index 7c0d854..bce69fc 100644 --- a/scripts/i18n-runtime.test.mjs +++ b/scripts/i18n-runtime.test.mjs @@ -28,6 +28,7 @@ function transpileModule(file, context = {}) { const { resolveAppLocale } = transpileModule('src/i18n/index.ts', { './ru': { russianLocale: {} }, + './en': { englishLocale: {} }, './runtime': { installUiLocale() {} }, }); @@ -35,8 +36,44 @@ test('system locale selects Russian and unsupported locales fall back to Chinese assert.equal(resolveAppLocale('ru'), 'ru'); assert.equal(resolveAppLocale('ru-RU'), 'ru'); assert.equal(resolveAppLocale('zh-CN'), 'zh-CN'); - assert.equal(resolveAppLocale('en-US'), 'zh-CN'); + assert.equal(resolveAppLocale('en-US'), 'en'); + assert.equal(resolveAppLocale('en-GB'), 'en'); + assert.equal(resolveAppLocale('de-DE'), 'zh-CN'); assert.equal(resolveAppLocale(''), 'zh-CN'); + assert.equal(resolveAppLocale('en-US', 'ru'), 'ru'); + assert.equal(resolveAppLocale('ru-RU', 'zh-CN'), 'zh-CN'); + assert.equal(resolveAppLocale('ru-RU', 'en'), 'en'); + assert.equal(resolveAppLocale('ru-RU', 'unsupported'), 'ru'); + assert.equal(resolveAppLocale('ru-RU', 'auto'), 'ru'); +}); + +const translator = transpileModule('src/i18n/translator.ts'); +const { translateEn, englishReplacements } = transpileModule('src/i18n/en.ts', { './translator': translator }); +test('English catalog covers every Russian source key and preserves placeholders', () => { + const english = new Map(englishReplacements); + const russianAst = ts.createSourceFile('ru.ts', source, ts.ScriptTarget.Latest, true); + function visit(node) { + if (ts.isVariableDeclaration(node) && node.name.getText(russianAst) === 'replacements') { + for (const pair of node.initializer.elements) { + const key = pair.elements[0].text; + assert.ok(english.has(key), `Missing English translation: ${key}`); + const value = english.get(key); + assert.ok(!/[\p{Script=Han}\p{Script=Cyrillic}]/u.test(value), `Untranslated English value: ${key}`); + assert.equal(value.match(/\{[^{}]*\}/g)?.length ?? 0, key.match(/\{[^{}]*\}/g)?.length ?? 0, key); + } + } + ts.forEachChild(node, visit); + } + visit(russianAst); +}); + +test('English dynamic messages preserve names and URLs', () => { + assert.equal(translateEn('账号'), 'Account'); + assert.equal(translateEn('模型列表请求失败: timeout'), 'Model list request failed: timeout'); + assert.equal(translateEn('Server 不可达(primary=http://a, fallback=http://b)'), 'Server unreachable (primary=http://a, fallback=http://b)'); + assert.equal(translateEn('请求失败: 生产账号A'), 'Request failed: 生产账号A'); + assert.equal(translateEn('1小时 2分钟 3秒 后重置'), '1h 2m 3s until reset'); + assert.equal(translateEn('3天 2小时 10分钟 后重置'), '3d 2h 10m until reset'); }); test('dynamic backend messages retain values and prefer the most specific template', () => { diff --git a/src-tauri/src/i18n/en.rs b/src-tauri/src/i18n/en.rs new file mode 100644 index 0000000..dddf1e1 --- /dev/null +++ b/src-tauri/src/i18n/en.rs @@ -0,0 +1,65 @@ +use super::{BackendLocale, APP_NAME}; + +pub struct EnglishLocale; + +impl BackendLocale for EnglishLocale { + fn oauth_success_html(&self) -> &'static str { + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n\ +

Authorization successful

OpenAI is connected. Close this window and return to the app.

\ + " + } + fn oauth_failure_response(&self) -> &'static str { + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nAuthorization failed: invalid state or missing parameters" + } + fn tray_tooltip_logged_out(&self) -> String { + format!("{APP_NAME} - Not signed in") + } + fn tray_tooltip_account(&self, name: &str, five_hour_left: f64, weekly_left: f64) -> String { + format!("{APP_NAME} - {name} | 5H: {five_hour_left:.0}% Weekly: {weekly_left:.0}%") + } + fn tray_show_main(&self) -> &'static str { + "Open main window" + } + fn tray_next_account(&self) -> &'static str { + "Switch to next account" + } + fn tray_quit(&self) -> &'static str { + "Quit" + } + fn notification_account_banned_subtitle(&self) -> &'static str { + "Account ban detected" + } + fn notification_auto_switch_subtitle(&self) -> &'static str { + "Automatic account switch" + } + fn injected_switch_message(&self, account_name: &str) -> String { + format!("⚡ [Codex Switcher] Switched to {account_name}") + } + fn referral_unsupported_program(&self) -> &'static str { + "Unsupported invitation campaign" + } + fn referral_uncertain_send_suffix(&self) -> &'static str { + "; send result unconfirmed. Check invitation records before retrying" + } + fn referral_network_error(&self, error: &reqwest::Error, uncertain: &str) -> String { + format!("Invitation API network error: {error}{uncertain}") + } + fn referral_non_json_response(&self, status: u16, uncertain: &str) -> String { + format!("Invitation API returned HTTP {status} with non-JSON data. An official desktop login may be required; eligibility cannot be confirmed{uncertain}") + } + fn referral_upstream_rejected(&self) -> &'static str { + "Request rejected by upstream" + } + fn referral_failed_emails(&self, emails: &serde_json::Value) -> String { + format!("; emails: {emails}") + } + fn referral_unrecognized_response(&self, uncertain: &str) -> String { + format!("Unrecognized invitation API response{uncertain}") + } + fn referral_missing_items(&self) -> &'static str { + "Invitation response lacks items; records cannot be confirmed" + } + fn referral_no_available_campaign(&self) -> &'static str { + "No invitation campaign available for this account. Refresh eligibility" + } +} diff --git a/src-tauri/src/i18n/mod.rs b/src-tauri/src/i18n/mod.rs index 0b889f6..549fad4 100644 --- a/src-tauri/src/i18n/mod.rs +++ b/src-tauri/src/i18n/mod.rs @@ -1,14 +1,16 @@ +mod en; mod ru; #[allow(dead_code)] mod zh_cn; +use en::EnglishLocale; use ru::RussianLocale; -use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::RwLock; use zh_cn::ChineseLocale; pub const APP_NAME: &str = "Codex Switcher"; -trait BackendLocale { +trait BackendLocale: Sync { fn oauth_success_html(&self) -> &'static str; fn oauth_failure_response(&self) -> &'static str; fn tray_tooltip_logged_out(&self) -> String; @@ -32,34 +34,65 @@ trait BackendLocale { const RUSSIAN_LOCALE: RussianLocale = RussianLocale; const CHINESE_LOCALE: ChineseLocale = ChineseLocale; -const LOCALE_AUTO: u8 = 0; -const LOCALE_ZH_CN: u8 = 1; -const LOCALE_RU: u8 = 2; -static ACTIVE_LOCALE: AtomicU8 = AtomicU8::new(LOCALE_AUTO); +const ENGLISH_LOCALE: EnglishLocale = EnglishLocale; + +struct BackendLocaleDefinition { + code: &'static str, + language_prefixes: &'static [&'static str], + locale: &'static dyn BackendLocale, +} + +static BACKEND_LOCALES: &[BackendLocaleDefinition] = &[ + BackendLocaleDefinition { + code: "en", + language_prefixes: &["en"], + locale: &ENGLISH_LOCALE, + }, + BackendLocaleDefinition { + code: "zh-CN", + language_prefixes: &["zh"], + locale: &CHINESE_LOCALE, + }, + BackendLocaleDefinition { + code: "ru", + language_prefixes: &["ru"], + locale: &RUSSIAN_LOCALE, + }, +]; + +static ACTIVE_LOCALE: RwLock> = RwLock::new(None); + +fn locale_by_code(code: &str) -> Option<&'static dyn BackendLocale> { + BACKEND_LOCALES + .iter() + .find(|entry| entry.code.eq_ignore_ascii_case(code)) + .map(|entry| entry.locale) +} fn active_locale() -> &'static dyn BackendLocale { - match ACTIVE_LOCALE.load(Ordering::Relaxed) { - LOCALE_RU => &RUSSIAN_LOCALE, - LOCALE_ZH_CN => &CHINESE_LOCALE, - _ => { - let language = std::env::var("LANG").unwrap_or_default().to_lowercase(); - if language.starts_with("ru") { - &RUSSIAN_LOCALE - } else { - &CHINESE_LOCALE - } + if let Ok(active) = ACTIVE_LOCALE.read() { + if let Some(locale) = *active { + return locale; } } + let language = std::env::var("LANG").unwrap_or_default().to_lowercase(); + BACKEND_LOCALES + .iter() + .find(|entry| { + entry + .language_prefixes + .iter() + .any(|prefix| language.starts_with(prefix)) + }) + .map(|entry| entry.locale) + .unwrap_or(&CHINESE_LOCALE) } #[tauri::command] pub fn set_app_locale(app: tauri::AppHandle, locale: String) -> Result<(), String> { - let value = match locale.as_str() { - "ru" => LOCALE_RU, - "zh-CN" => LOCALE_ZH_CN, - _ => return Err(format!("Unsupported application locale: {locale}")), - }; - ACTIVE_LOCALE.store(value, Ordering::Relaxed); + let selected = locale_by_code(&locale) + .ok_or_else(|| format!("Unsupported application locale: {locale}"))?; + *ACTIVE_LOCALE.write().map_err(|error| error.to_string())? = Some(selected); crate::tray::update_tray_native_menu(&app).map_err(|error| error.to_string())?; crate::tray::update_tray_menu(&app); Ok(()) @@ -192,3 +225,22 @@ pub fn referral_missing_items() -> &'static str { pub fn referral_no_available_campaign() -> &'static str { active_locale().referral_no_available_campaign() } + +#[cfg(test)] +mod tests { + use super::locale_by_code; + + #[test] + fn english_native_ui_preserves_account_identity() { + let locale = locale_by_code("en").unwrap(); + assert_eq!(locale.tray_show_main(), "Open main window"); + assert!(locale.oauth_success_html().contains("lang=\"en\"")); + assert!(locale + .tray_tooltip_account("生产账号A", 73.0, 42.0) + .contains("生产账号A")); + assert!(locale + .injected_switch_message("生产账号A") + .contains("生产账号A")); + assert!(locale_by_code("de").is_none()); + } +} diff --git a/src/App.css b/src/App.css index d600d60..8ebd39e 100644 --- a/src/App.css +++ b/src/App.css @@ -156,6 +156,7 @@ body { display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 16px 24px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); @@ -165,6 +166,7 @@ body { display: flex; align-items: center; gap: 10px; + flex: 0 0 auto; } .app-logo { @@ -184,6 +186,7 @@ body { font-size: 18px; font-weight: 700; color: var(--text-primary); + white-space: nowrap; } .app-version { @@ -234,12 +237,80 @@ body { .header-actions { display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.language-picker { + position: relative; + flex: 0 0 auto; +} + +.language-picker-trigger { + width: 34px; + height: 32px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--input-bg); + cursor: pointer; + font-size: 17px; + line-height: 1; + list-style: none; +} + +.language-picker-trigger:hover, +.language-picker.open .language-picker-trigger { + background: var(--hover-bg); + border-color: var(--primary-color); +} + +.language-picker-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 100; + width: 178px; + padding: 4px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--card-bg); + box-shadow: 0 8px 24px rgba(31, 35, 40, 0.16); + max-height: min(360px, calc(100vh - 96px)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.language-picker-menu button { + width: 100%; + min-height: 34px; + display: grid; + grid-template-columns: 22px 1fr 14px; + align-items: center; gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.language-picker-menu button:hover, +.language-picker-menu button.selected { + background: var(--hover-bg); } /* 导航菜单 */ .header-nav { display: flex; + flex: 0 1 auto; gap: 4px; background: var(--section-bg); padding: 4px; @@ -270,6 +341,70 @@ body { background: var(--badge-bg); } +@media (max-width: 1400px) { + html[lang^='ru'] .app-header { + padding-inline: 16px; + } + + html[lang^='ru'] .nav-item { + padding-inline: 9px; + font-size: 12px; + } + + html[lang^='ru'] .header-actions .btn { + padding-inline: 10px; + } +} + +@media (max-width: 1280px) { + html[lang^='ru'] .app-header { + gap: 8px; + padding-inline: 12px; + } + + html[lang^='ru'] .header-left { + gap: 8px; + } + + html[lang^='ru'] .header-left h1 { + font-size: 16px; + } + + html[lang^='ru'] .app-version { + display: none; + } + + html[lang^='ru'] .proxy-indicator { + padding-inline: 8px; + } + + html[lang^='ru'] .nav-item { + padding-inline: 7px; + font-size: 11px; + } + + html[lang^='ru'] .header-actions { + gap: 6px; + } + + html[lang^='ru'] .header-actions .btn { + padding-inline: 8px; + font-size: 12px; + } +} + +@media (max-width: 1100px) { + .app-header { + flex-wrap: wrap; + } + + .header-nav { + order: 3; + width: 100%; + justify-content: center; + } +} + .app-main { flex: 1; @@ -680,4 +815,4 @@ body { font-size: 14px; color: var(--text-secondary); max-width: 320px; -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 0fcb642..b1a5c17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { SessionRoutes } from './components/SessionRoutes'; import CachePanel from './components/CachePanel'; import { ConfirmModal } from './components/ConfirmModal'; import { RelayImportConfirm } from './components/RelayImportConfirm'; +import { LanguagePicker } from './components/LanguagePicker'; import './App.css'; import { isMacOS } from './platform'; @@ -370,6 +371,7 @@ function App() {
+ diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index cfa9344..dd5eed2 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -78,7 +78,7 @@ export function Dashboard({ {/* 问候语 */}

- 你好, {currentAccount?.name.split('@')[0] || '用户'} 👋 + 你好, {currentAccount ? {currentAccount.name.split('@')[0]} : '用户'} 👋

diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..18345c8 --- /dev/null +++ b/src/components/LanguagePicker.tsx @@ -0,0 +1,82 @@ +import { Check } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { + AUTO_LOCALE, + appLocales, + getActiveLocale, + getLocalePreference, + setLocalePreference, +} from '../i18n'; + +export function LanguagePicker() { + const [open, setOpen] = useState(false); + const pickerRef = useRef(null); + const triggerRef = useRef(null); + const activeLocale = getActiveLocale(); + const preference = getLocalePreference(); + + useEffect(() => { + if (!open) return undefined; + + const handlePointerDown = (event: PointerEvent) => { + if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + return ( +
+ + {open &&
+ + {appLocales.map(locale => { + const isSelected = preference === locale.code || ( + preference === AUTO_LOCALE && activeLocale.code === locale.code + ); + return ( + + ); + })} +
} +
+ ); +} diff --git a/src/components/Settings.css b/src/components/Settings.css index a2d5d2e..cbb8ce1 100644 --- a/src/components/Settings.css +++ b/src/components/Settings.css @@ -358,6 +358,58 @@ transform: translateY(-1px); } +.translation-credit-list { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.translation-credit { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.translation-credit-language, +.translation-contributor { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.translation-credit-language { + color: var(--text-secondary); + font-weight: 500; +} + +.translation-contributor { + color: var(--primary-color); + text-decoration: none; +} + +a.translation-contributor:hover { + text-decoration: underline; +} + +@media (max-width: 720px) { + .translation-credits { + align-items: flex-start; + gap: 12px; + } + + .translation-credit-list { + align-items: flex-start; + } + + .translation-credit { + justify-content: flex-start; + } +} + /* ===== 手机锚 (Session Anchor) ===== */ .anchor-actions { display: flex; @@ -611,4 +663,4 @@ background: rgba(148, 163, 184, 0.12); color: var(--text-muted); border-color: rgba(148, 163, 184, 0.25); -} \ No newline at end of file +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 1b50be0..13b887c 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -5,6 +5,7 @@ import { Account, effectiveKind } from '../hooks/useAccounts'; import { formatPlanLabel } from '../utils/planLabel'; import './Settings.css'; import { isMacOS } from '../platform'; +import { appLocales, getActiveLocale, SOURCE_LOCALE_CODE } from '../i18n'; interface AppSettings { auto_reload_ide: boolean; @@ -861,6 +862,43 @@ export function Settings({ accounts = [], onSetSessionAnchor }: SettingsProps = GitHub
+ {getActiveLocale().code !== SOURCE_LOCALE_CODE && ( +
+
+ 翻译 + 感谢帮助应用支持更多语言的贡献者 +
+
+ {appLocales + .filter(locale => locale.translation && locale.contributors?.length) + .map(locale => ( +
+ + + {locale.nativeName} + + {locale.contributors?.map(contributor => contributor.url ? ( + + + ) : ( + + {contributor.name} + + ))} +
+ ))} +
+
+ )} ); diff --git a/src/components/Skills.tsx b/src/components/Skills.tsx index 61c6b4d..be09651 100644 --- a/src/components/Skills.tsx +++ b/src/components/Skills.tsx @@ -316,7 +316,7 @@ export function Skills() {
handleOpenDetail(skill)} style={{ cursor: 'pointer' }}>
{skill.name}
-
{skill.description || '无描述'}
+
{skill.description ? {skill.description} : '无描述'}
{skill.source === 'github' && skill.repo_owner && ( {skill.repo_owner}/{skill.repo_name} @@ -348,7 +348,7 @@ export function Skills() {
{skill.name}
-
{skill.description || '无描述'}
+
{skill.description ? {skill.description} : '无描述'}
{skill.repo_owner}/{skill.repo_name}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..7e3a87d --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,1669 @@ +import type { UiLocale } from './runtime'; +import { createTranslator } from './translator'; + +// Earlier UI translations adapted from InventiveSpark/codex-switcher (MIT). +// Additional translations cover the current UI and preserve source placeholders. +export const englishReplacements: readonly (readonly [string, string])[] = [ + ["切换语言","Switch language"], + ["跟随系统","Follow system"], + ["Turn-State 被动观测","Passive Turn-State observations"], + ["响应头观测","Response header observations"], + ["292 正常","292 normal"], + ["312/356 受限形状","312/356 restricted shape"], + ["未知/异常","Unknown / abnormal"], + ["仅读取上游响应头,不主动探测、不注入、不重放;长度和时间判断是经验性观测,不代表官方质量指标。","Only upstream response headers are observed, without active probes, injection or replay. Length and timing assessments are empirical observations, not official quality metrics."], + ["账号 / 模型","Account / model"], + ["形状","Shape"], + ["时间状态","Time status"], + ["WS 握手","WS handshake"], + ["HTTP 响应","HTTP response"], + ["代理启动后,收到官方 ChatGPT HTTP/SSE 或 WebSocket 握手响应头才会出现观测。","Observations appear after the proxy receives official ChatGPT HTTP/SSE or WebSocket handshake response headers."], + ["受限","Restricted"], + ["格式错误","Invalid format"], + ["未知封装","Unknown envelope"], + ["其他长度","Other length"], + ["OAuth 回调任务锁异常","OAuth callback task lock error"], + ["OpenAI 授权已超时,请重新发起登录。","OpenAI authorization timed out. Please start login again."], + ["无法监听 OpenAI 授权回调: {}","Cannot listen for OpenAI authorization callback: {}"], + ["模型列表请求失败: {}","Model list request failed: {}"], + ["模型列表 JSON 解析失败: {}","Failed to parse model list JSON: {}"], + ["上游拒绝了模型列表请求","Upstream rejected the model list request"], + ["上游模型列表为空或响应格式不识别","The upstream model list is empty or has an unknown format"], + ["中转账号不可用","Relay account unavailable"], + ["请先配置此中转的模型 ID","Configure the model ID for this relay first"], + ["该账号未配置这个模型","This model is not configured for the account"], + ["设置文件权限失败: {}","Failed to set file permissions: {}"], + ["设置目录权限失败: {}","Failed to set directory permissions: {}"], + ["写入文件失败: {}","Failed to write file: {}"], + ["Google 账号不存在","Google account not found"], + ["只能选择 Google Antigravity 账号","Only Google Antigravity accounts can be selected"], + ["Google 账号不可用,请先重新授权","Google account unavailable. Please authorize again"], + ["创建目录失败: {}","Failed to create directory: {}"], + ["序列化失败: {}","Serialization failed: {}"], + ["读取 auth.json 失败: {}","Failed to read auth.json: {}"], + ["解析 auth.json 失败: {}","Failed to parse auth.json: {}"], + ["写入临时文件失败: {}","Failed to write temp file: {}"], + ["重命名文件失败 (Atomic Write): {}","Atomic write failed (rename): {}"], + ["账号不存在: {}","Account not found: {}"], + ["导出失败: {}","Export failed: {}"], + ["导入失败: {}","Import failed: {}"], + ["Base64 解码失败: {}","Base64 decode failed: {}"], + ["base64 解码失败: {}","Base64 decoding failed: {}"], + ["UTF-8 转换失败: {}","UTF-8 conversion failed: {}"], + ["JSON 解析失败: {}","JSON parse failed: {}"], + ["无法定位 Windows 系统目录","Cannot locate the Windows system directory"], + ["无法启动剪贴板工具: {}","Cannot start clipboard utility: {}"], + ["剪贴板输入不可写","Clipboard input is not writable"], + ["写入剪贴板失败: {}","Failed to write to clipboard: {}"], + ["等待剪贴板工具失败: {}","Failed to wait for clipboard utility: {}"], + ["剪贴板工具退出失败: {}","Clipboard utility exited with an error: {}"], + ["剪贴板写入超时,请手动复制","Clipboard write timed out. Please copy manually"], + ["创建临时文件失败: {}","Failed to create temporary file: {}"], + ["重命名临时文件失败: {}","Failed to rename temporary file: {}"], + ["邀请奖励或活动条件已变化,请刷新资格后确认","Referral rewards or eligibility changed. Refresh eligibility and confirm"], + ["当前没有剩余奖励名额,已阻止发送邀请","No reward slots remain. Invitation sending was blocked"], + ["本次最多可邀请 {cap} 个邮箱","Invite up to {cap} email addresses at a time"], + ["邮箱格式不正确","Invalid email address"], + ["发送结果缺少 invites,请先查询邀请记录,勿直接重发","The send result lacks invites. Check invitation records before retrying"], + ["无法绑定 Antigravity OAuth 回调端口 {CALLBACK_PORT}: {e}","Cannot bind Antigravity OAuth callback port {CALLBACK_PORT}: {e}"], + ["登录流程状态锁异常","Login flow state lock error"], + ["Antigravity 登录流程已过期或未启动","Antigravity login has expired or was not started"], + ["Google 未返回模型额度,请稍后重试;这不表示额度为 0","Google returned no model quota. Try again later; this does not mean quota is zero"], + ["zip 解析失败: {}","Failed to parse zip: {}"], + ["zip entry {} 失败: {}","Zip entry {} failed: {}"], + ["{} 解析 JSON 失败: {}","{} failed to parse JSON: {}"], + ["{} 缺 credentials","{} is missing credentials"], + ["无","None"], + ["未找到 IDE {} 的配置","IDE configuration not found for {}"], + ["不由自主","not allowed"], + ["不允许发送按键","keystroke not allowed"], + ["无法执行 osascript: {}","Failed to execute osascript: {}"], + ["AppleScript 执行失败: {}","AppleScript execution failed: {}"], + ["store lock 失败","Store lock failed"], + ["client/solo 模式禁止本机 rt 刷新;Server 不可达,跳过本机降级","Local refresh tokens cannot be refreshed in client/solo mode. Server unavailable; local fallback skipped"], + ["chat 入站选中的账号不存在: {}","Account selected for inbound chat does not exist: {}"], + ["chat 入站选中的账号缺 access_token: {}","Account selected for inbound chat lacks access_token: {}"], + ["上游请求失败: {}","Upstream request failed: {}"], + ["current 已登出","Current account is logged out"], + ["current 没有 refresh_token","Current account has no refresh_token"], + ["上游 HTTP {}","Upstream HTTP {}"], + ["读取请求体失败","Failed to read request body"], + ["Responses Relay 缺少 API key","Responses relay is missing an API key"], + ["上游连接失败: {}","Upstream connection failed: {}"], + ["响应构建失败","Response build failed"], + ["账号已登出/RT 被轮换","Account logged out or refresh token rotated"], + ["缺 refresh_token 无法刷新","Cannot refresh without refresh_token"], + ["瞬时刷新失败: {}","Transient refresh failure: {}"], + ["5xx 透传","5xx pass-through"], + ["4xx 透传失败","4xx pass-through failed"], + ["所有账号额度已耗尽,最早恢复:{}","All account quotas exhausted, earliest recovery: {}"], + ["所有账号额度已耗尽","All account quotas exhausted"], + ["Server 不可达: {}","Server unreachable: {}"], + ["无法从 {} 构造 Server proxy URL","Cannot construct Server proxy URL from {}"], + ["构建 no_proxy client 失败: {}","Failed to build no_proxy client: {}"], + ["转发到 Server 失败: {e:?}","Failed to forward to Server: {e:?}"], + ["silent_retry: 读首 chunk 失败: {}","silent_retry: failed to read first chunk: {}"], + ["未配置 remote_shared_secret","remote_shared_secret is not configured"], + ["转发请求失败: {}","Forward request failed: {}"], + ["流构建失败","Stream build failed"], + ["锁失败","Lock failed"], + ["WebSocket 请求构建失败: {}","WebSocket request build failed: {}"], + ["刷新后 WebSocket 仍连接失败: {}","WebSocket connection still failed after refresh: {}"], + ["101 构建失败","101 build failed"], + ["translator 请求处理失败: {}","Translator request failed: {}"], + ["Relay base_url 未配置","Relay base_url is not configured"], + ["Relay base_url 解析失败","Failed to parse relay base_url"], + ["relay 上游连接失败: {}","Relay upstream connection failed: {}"], + ["relay 上游 {}: {}","Relay upstream {}: {}"], + ["sync 响应翻译失败: {}","Failed to translate synchronous response: {}"], + ["relay SSE 上游错误: {}","Relay SSE upstream error: {}"], + ["StepFun /models 请求失败: {}","StepFun /models request failed: {}"], + ["上游响应构建失败","Failed to build upstream response"], + ["网络请求失败: {}","Network request failed: {}"], + ["wham/usage 请求失败","wham/usage request failed"], + ["刷新后重试失败: {}","Retry after refresh failed: {}"], + ["刷新后重试失败","Retry failed after refresh"], + ["ACCOUNT_LOGGED_OUT:您已登出或登录了其他账号,请重新登录","ACCOUNT_LOGGED_OUT:You have logged out or signed in to another account, please sign in again"], + ["ACCOUNT_BANNED:该账号已被封禁","ACCOUNT_BANNED:This account has been banned"], + ["当前激活账号访问配额接口返回 401/403;已禁用本地 refresh_token 刷新,请稍后重试或在 Codex 中触发一次请求","Current active account quota API returned 401/403; local refresh_token refresh is disabled, please retry later or trigger a request in Codex"], + ["TOKEN_INVALID:授权已失效,请删除该账号后重新登录","TOKEN_INVALID:Authorization expired, please delete this account and sign in again"], + ["读取响应失败: {}","Failed to read response body: {}"], + ["解析 JSON 失败: {}","Failed to parse JSON response: {}"], + ["ACCOUNT_BANNED:该账号已被封禁(workspace 已停用)","ACCOUNT_BANNED:This account has been banned (workspace deactivated)"], + ["{}H 限额","{}H Limit"], + ["{}天后重置","Resets in {} days"], + ["{}小时{}分钟后重置","Resets in {}h {}m"], + ["{}分钟后重置","Resets in {}m"], + ["usage 请求失败: {}","Usage request failed: {}"], + ["usage JSON 解析失败: {}","Failed to parse usage JSON: {}"], + ["上游响应缺 remaining/balance 字段","Upstream response lacks remaining/balance"], + ["subscription 请求失败: {}","Subscription request failed: {}"], + ["subscription JSON 解析失败: {}","Failed to parse subscription JSON: {}"], + ["subscription 缺 soft_limit_usd 字段","Subscription lacks soft_limit_usd"], + ["无法从 base_url 解析 origin: {}","Cannot parse origin from base_url: {}"], + ["MiMo Cookie 缺少 api-platform_serviceToken 或 userId","MiMo Cookie lacks api-platform_serviceToken or userId"], + ["MiMo usage 响应缺 monthUsage.items","MiMo usage response lacks monthUsage.items"], + ["MiMo usage 响应缺 percent/used/limit","MiMo usage response lacks percent/used/limit"], + ["StepFun 额度查询需要 platform.stepfun.com 的 Oasis-Token","StepFun quota queries require the Oasis-Token from platform.stepfun.com"], + ["StepFun 额度请求失败: {}","StepFun quota request failed: {}"], + ["StepFun 额度 JSON 解析失败: {}","Failed to parse StepFun quota JSON: {}"], + ["上游拒绝了额度查询","Upstream rejected the quota query"], + ["订阅 Credit","Subscription credits"], + ["加油包 Credit","Top-up credits"], + ["响应中没有可识别的 Step Plan 额度字段","No recognizable Step Plan quota fields in response"], + ["StepFun 额度解析失败: {}","Failed to parse StepFun quota: {}"], + ["MiMo usage 请求失败: {}","MiMo usage request failed: {}"], + ["MiMo 登录态已失效,请重新登录后复制 Cookie","MiMo session expired. Sign in again and copy the Cookie"], + ["MiMo Cookie 无效或权限不足","Invalid MiMo Cookie or insufficient permissions"], + ["MiMo usage JSON 解析失败: {}","Failed to parse MiMo usage JSON: {}"], + ["至少需要 1 个邀请邮箱","At least one invitation email is required"], + ["邀请 {} 获得","Received for inviting {}"], + ["Codex 赠送","Codex gift"], + ["未知来源","Unknown source"], + ["token 失效(401),请刷新或重新登录","Token invalid (401). Refresh or sign in again"], + ["需要验证(403)","Verification required (403)"], + ["请求过于频繁(429),稍后再试","Too many requests (429). Try again later"], + ["上游返回 HTTP {}","Upstream returned HTTP {}"], + ["上游返回非 JSON","Upstream returned non-JSON data"], + ["上游未返回有效的重置银行明细,当前次数未知(不代表已清空)","Upstream returned no valid reset-bank details. Available count is unknown, not necessarily zero"], + ["已重置 {} 个限额窗口","Reset {} quota windows"], + ["当前没有可重置的限额(额度还没用到上限)","No quota windows can be reset; limits have not been reached"], + ["上游返回:{}","Upstream response: {}"], + ["配额已耗尽(usage_limit_reached)","Quota exhausted (usage_limit_reached)"], + ["token 失效(401)","Token invalid (401)"], + ["需要验证或提交保证书(403)","Verification or attestation required (403)"], + ["配额已耗尽(429)","Quota exhausted (429)"], + ["Server 不可达(fallback={})","Server unreachable (fallback={})"], + ["Server 不可达(primary={})","Server unreachable (primary={})"], + ["Server 不可达(primary={}, fallback={})","Server unreachable (primary={}, fallback={})"], + ["Server Google token 租约失败: {error}","Server Google token lease failed: {error}"], + ["Server Google token 租约失败","Server Google token lease failed"], + ["构建 HTTP client 失败: {}","Failed to build HTTP client: {}"], + ["连接 Server 失败: {}","Failed to connect to Server: {}"], + ["Server /health 返回 {}","Server /health returned {}"], + ["解析 /health 响应失败: {}","Failed to parse /health response: {}"], + ["连接失败: {}","Connection failed: {}"], + ["共享密钥不正确","Incorrect shared secret"], + ["Server /accounts 返回 {}","Server /accounts returned {}"], + ["请求失败: {}","Request failed: {}"], + ["Server 返回 {}","Server returned {}"], + ["响应缺少 accounts 字段","Response lacks accounts field"], + ["反序列化账号列表失败: {}","Failed to deserialize accounts: {}"], + ["创建 SSOT 目录失败: {}","Failed to create SSOT directory: {}"], + ["读取目录失败: {}","Failed to read directory: {}"], + ["移除旧 Antigravity skills 链接失败: {}","Failed to remove old Antigravity skills link: {}"], + ["移除旧 Antigravity skills 目录失败: {}","Failed to remove old Antigravity skills directory: {}"], + ["移除旧 Antigravity skill 失败: {}","Failed to remove old Antigravity skill: {}"], + ["移除旧 Antigravity skills manifest 失败: {}","Failed to remove old Antigravity skills manifest: {}"], + ["Antigravity skills 目录不可用","Antigravity skills directory unavailable"], + ["移除 Antigravity skills symlink 失败: {}","Failed to remove Antigravity skills symlink: {}"], + ["Antigravity skills 路径不是目录: {:?}","Antigravity skills path is not a directory: {:?}"], + ["创建 Antigravity skills 目录失败: {}","Failed to create Antigravity skills directory: {}"], + ["移除过期 Antigravity skill {} 失败: {}","Failed to remove expired Antigravity skill {}: {}"], + ["覆盖 Antigravity skill {} 失败: {}","Failed to overwrite Antigravity skill {}: {}"], + ["生成 Antigravity skills manifest 失败: {}","Failed to generate Antigravity skills manifest: {}"], + ["写入 Antigravity skills manifest 失败: {}","Failed to write Antigravity skills manifest: {}"], + ["移除 Antigravity skill 失败: {}","Failed to remove Antigravity skill: {}"], + ["移除 Antigravity skills manifest 失败: {}","Failed to remove Antigravity skills manifest: {}"], + ["创建 symlink 失败: {}","Failed to create symlink: {}"], + ["读取条目失败: {}","Failed to read entry: {}"], + ["复制文件失败: {}","Failed to copy file: {}"], + ["备份文件失败: {}","Failed to back up file: {}"], + ["写入失败: {}","Write failed: {}"], + ["未知 app: {}","Unknown app: {}"], + ["下载失败: {}","Download failed: {}"], + ["下载失败: HTTP {}","Download failed: HTTP {}"], + ["读取失败: {}","Read failed: {}"], + ["在仓库中未找到 skill: {}","Skill not found in repo: {}"], + ["skill 不存在: {}","Skill does not exist: {}"], + ["打开 ZIP 失败: {}","Failed to open ZIP: {}"], + ["读取 ZIP 条目失败: {}","Failed to read ZIP entry: {}"], + ["创建文件失败: {}","Failed to create file: {}"], + ["skill 目录不存在: {}","Skill directory not found: {}"], + ["读取目录失败 {:?}: {}","Failed to read directory {:?}: {}"], + ["添加目录失败: {}","Failed to add directory: {}"], + ["开始文件失败: {}","Failed to start file: {}"], + ["打开文件失败 {:?}: {}","Failed to open file {:?}: {}"], + ["读取文件失败: {}","Failed to read file: {}"], + ["写入 zip 失败: {}","Failed to write zip: {}"], + ["finish zip 失败: {}","Failed to finish zip: {}"], + ["非法 skill 名: {}","Invalid skill name: {}"], + ["创建 SSOT 失败: {}","Failed to create SSOT: {}"], + ["创建 staging 失败: {}","Failed to create staging directory: {}"], + ["打开 zip 失败: {}","Failed to open zip: {}"], + ["读取 zip 条目失败: {}","Failed to read zip entry: {}"], + ["zip 条目名非法: {}","Invalid zip entry name: {}"], + ["zip 条目缺少顶层目录","Zip entry lacks top-level directory"], + ["zip 顶层目录 {:?} 与期望 {} 不一致","Zip top-level directory {:?} does not match expected {}"], + ["创建父目录失败: {}","Failed to create parent directory: {}"], + ["创建文件失败 {:?}: {}","Failed to create file {:?}: {}"], + ["读取 zip 内容失败: {}","Failed to read zip contents: {}"], + ["备份原目录失败: {}","Failed to back up original directory: {}"], + ["替换 skill 失败: {}","Failed to replace skill: {}"], + ["sentinel/req 请求失败: {e}","sentinel/req request failed: {e}"], + ["sentinel/req 读取响应失败: {e}","Failed to read sentinel/req response: {e}"], + ["sentinel/req JSON 解析失败: {e}","Failed to parse sentinel/req JSON: {e}"], + ["构建 HTTP client 失败: {e}","Failed to build HTTP client: {e}"], + ["/oauth/authorize 请求失败: {e}","/oauth/authorize request failed: {e}"], + ["authorize/continue 失败: {e}","authorize/continue failed: {e}"], + ["authorize/continue 读响应失败: {e}","Failed to read authorize/continue response: {e}"], + ["send-otp 失败: {e}","send-otp failed: {e}"], + ["send-otp 非 200: {status} body={}","send-otp returned non-200: {status} body={}"], + ["email-otp/validate 失败: {e}","email-otp/validate failed: {e}"], + ["session_dump 失败: {e}","session_dump failed: {e}"], + ["workspace/select 失败: {e}","workspace/select failed: {e}"], + ["无法获取 continue_url,OTP 校验/workspace 选择都没给出,无法继续","Cannot obtain continue_url from OTP validation or workspace selection"], + ["follow {url} 失败: {e}","Failed to follow {url}: {e}"], + ["follow_until_callback 超过 15 跳","follow_until_callback exceeded 15 redirects"], + ["URL 解析失败: {e}","Failed to parse URL: {e}"], + ["URL 里没有 code 参数: {url}","URL lacks code parameter: {url}"], + ["请求令牌失败: {}","Token request failed: {}"], + ["OpenAI 返回错误: {}","OpenAI returned error: {}"], + ["解析令牌响应失败: {}","Failed to parse token response: {}"], + ["store 锁中毒","Store lock poisoned"], + ["刷新令牌失败: {}","Token refresh failed: {}"], + ["刷新令牌被拒绝: {}","Token refresh rejected: {}"], + ["解析刷新响应失败: {}","Failed to parse refresh response: {}"], + ["OTP 等待超时","OTP wait timed out"], + ["nissanserena GET 失败: {e}","nissanserena GET failed: {e}"], + ["nissanserena 读响应失败: {e}","Failed to read nissanserena response: {e}"], + ["回调链接不能为空","Callback URL cannot be empty"], + ["未能从输入中解析出 code 参数","Cannot parse code parameter from input"], + ["state 校验不通过:这个回调链接不属于本次登录流程","State validation failed: callback does not belong to this login"], + ["登录流程已过期或未启动,请先点击『立即登录 OpenAI』","Login expired or not started. Click Sign in to OpenAI first"], + ["派发 oauth-callback-received 失败: {}","Failed to emit oauth-callback-received: {}"], + ["无法启动 pbcopy: {}","Cannot start pbcopy: {}"], + ["pbcopy stdin 不可写","pbcopy stdin is not writable"], + ["写入 pbcopy 失败: {}","Failed to write to pbcopy: {}"], + ["等待 pbcopy 退出失败: {}","Failed to wait for pbcopy exit: {}"], + ["pbcopy 返回非零: {:?}","pbcopy returned nonzero: {:?}"], + ["锁被污染","Lock poisoned"], + ["登录流程已过期或未启动","Login expired or not started"], + ["锁获取失败: {}","Failed to acquire lock: {}"], + ["Google ST 获取失败","Failed to obtain Google ST"], + ["Google ST 缺失","Google ST missing"], + ["Google project_id 缺失","Google project_id missing"], + ["账号已被删除","Account has been deleted"], + ["读取 OAuth body 失败: {error}","Failed to read OAuth body: {error}"], + ["OAuth JSON 解析失败: {error}","Failed to parse OAuth JSON: {error}"], + ["读取 body 失败: {}","Failed to read body: {}"], + ["保存失败: {}","Save failed: {}"], + ["锁获取失败","Failed to acquire lock"], + ["序列化响应失败: {}","Failed to serialize response: {}"], + ["刷新 token 失败: {}","Failed to refresh token: {}"], + ["无 access_token 且无 refresh_token","Neither access_token nor refresh_token is available"], + ["TOKEN_INVALID:刷新 token 失败: {}","TOKEN_INVALID:Token refresh failed: {}"], + ["switch_to 失败: {}","switch_to failed: {}"], + ["加载图标失败: {}","Failed to load icon: {}"], + ["安全确认已过期,请重新点击修复","Security confirmation expired, please click fix again"], + ["安全确认无效,请重新点击修复","Security confirmation invalid, please click fix again"], + ["缺少安全确认,请重新点击修复","Missing security confirmation, please click fix again"], + ["当前 auth.json 缺少 refresh_token,无法自动续期,请重新登录","inactive keep-alive refresh"], + ["仅 ChatGPT OAuth 订阅账号支持周期保鲜","Only ChatGPT OAuth subscription accounts support window priming"], + ["base_url 必须以 http:// 或 https:// 开头","base_url must start with http:// or https://"], + ["api_key 不能为空","api_key cannot be empty"], + ["name 不能为空","name cannot be empty"], + ["中转站账号缺 base_url","Relay account lacks base_url"], + ["中转站账号缺 api_key","Relay account lacks api_key"], + ["账号在刷新模型时被删除","Account deleted during model refresh"], + ["账号在刷新模型时发生变化","Account changed during model refresh"], + ["账号 {} 不存在","Account {} not found"], + ["自动探测未命中:上游不支持 /v1/dashboard/billing 或 /v1/usage(可手动选 usage 策略,或保持「不拉取」)","Auto-detection found no match: upstream does not support /v1/dashboard/billing or /v1/usage. Select a usage strategy manually or keep quota fetching disabled"], + ["MiMo 配额查询需要登录 platform.xiaomimimo.com 后复制 Cookie header","MiMo quota queries require signing in to platform.xiaomimimo.com and copying the Cookie header"], + ["StepFun 额度查询需要登录 platform.stepfun.com 后复制 Oasis-Token","StepFun quota queries require signing in to platform.stepfun.com and copying Oasis-Token"], + ["未支持的 usage_preset: {}","Unsupported usage_preset: {}"], + ["usage 策略未确定","Usage strategy not determined"], + ["OAuth 未返回 refresh_token,无法自动续期","OAuth did not return refresh_token, cannot auto-renew"], + ["无法从授权响应中解析用户信息 (Missing ID Token)","Failed to parse user info from authorization response (Missing ID Token)"], + ["没有当前激活账号","No active account"], + ["已覆盖 ~/.codex/auth.json","Overwrote ~/.codex/auth.json"], + ["OpenAI OAuth 登录","current active account"], + ["该账号不是 Google Antigravity 账号","This is not a Google Antigravity account"], + ["Antigravity 账号由模型路由自动选择,不切换 Codex 当前身份","Antigravity accounts are selected by model routing without changing the Codex identity"], + ["账号缺少 access_token","Account missing access_token"], + ["该账号正在被其他流程刷新,请稍后重试","This account is being refreshed by another process, please try again later"], + ["仅 solo 模式支持同号操作","Same-account operations require solo mode"], + ["没有可用账号","No available accounts"], + ["遍历完所有账号,未发现可用配额的账号","Checked all accounts, no account with available quota found"], + ["PROVIDER_ACCOUNT:该 Provider 账号不支持 OpenAI usage 查询","PROVIDER_ACCOUNT: this provider account does not support OpenAI usage queries"], + ["TOKEN_INVALID:client/solo 模式禁止本机 rt 刷新;请确认账号已同步到 Server","TOKEN_INVALID: local refresh tokens cannot be refreshed in client/solo mode. Ensure the account is synchronized to Server"], + ["TOKEN_INVALID:无 access_token 且无 refresh_token","TOKEN_INVALID:No access_token and no refresh_token"], + ["邀请邮箱过多:{} 个,最多 50 个","Too many invitation emails: {}; maximum is 50"], + ["PROVIDER_ACCOUNT:该 Provider 账号不支持 Codex 邀请","PROVIDER_ACCOUNT: this provider account does not support Codex referrals"], + ["你好","Hello"], + ["UNSUPPORTED_ACCOUNT:仅 ChatGPT OAuth 订阅账号支持 Codex 唤醒","UNSUPPORTED_ACCOUNT: only ChatGPT OAuth subscription accounts support Codex wakeup"], + ["PROVIDER_ACCOUNT:该 Provider 账号不支持 codex 终端","PROVIDER_ACCOUNT: this provider account does not support the Codex terminal"], + ["账号 auth_json 结构异常","Invalid account auth_json structure"], + ["账号缺少 codex tokens,无法启动","Account lacks Codex tokens; cannot start"], + ["创建 CODEX_HOME 失败: {}","Failed to create CODEX_HOME: {}"], + ["写 auth.json 失败: {}","Failed to write auth.json: {}"], + ["写 config.toml 失败: {}","Failed to write config.toml: {}"], + ["打开 Terminal 失败: {}","Failed to open Terminal: {}"], + ["已为 {} 打开 codex 终端","Opened Codex terminal for {}"], + ["同步失败:账号不存在或 User ID 不匹配","Sync failed: Account not found or User ID mismatch"], + ["PROVIDER_ACCOUNT:该 Provider 账号不支持 OpenAI usage","PROVIDER_ACCOUNT: this provider account does not support OpenAI usage"], + ["当前激活账号与 ~/.codex/auth.json 身份不匹配,已拒绝覆盖,请先在 Codex 中切回同一账号","Current active account identity mismatch with ~/.codex/auth.json, overwrite rejected. Please switch to same account in Codex first"], + ["session route 不存在: {}","Session route not found: {}"], + ["执行失败: {}","Execution failed: {}"], + ["已终止 {} 个 codex 进程","Terminated {} codex processes"], + ["读取 {} 失败: {}","Failed to read {}: {}"], + ["写入 {} 失败: {}","Failed to write {}: {}"], + ["config.toml(失败: {})","config.toml(failed: {})"], + ["创建 config.toml 失败: {}","Failed to create config.toml: {}"], + ["读取 config.toml 失败: {}","Failed to read config.toml: {}"], + ["写入 config.toml 失败: {}","Failed to write config.toml: {}"], + ["~/.codex/config.toml 不存在","~/.codex/config.toml does not exist"], + ["Fast 模式已开启(2x 额度消耗,更快推理)。重启 Codex 生效。","Fast mode enabled (2x quota consumption, faster inference). Restart Codex to apply."], + ["Fast 模式已关闭。重启 Codex 生效。","Fast mode disabled. Restart Codex to apply."], + ["[features] goals = true 已写入。重启 Codex 生效。","[features] goals = true written. Restart Codex to apply."], + ["[features] goals 已关闭。重启 Codex 生效。","[features] goals disabled. Restart Codex to apply."], + ["本地未找到账号 {}","Account {} not found locally"], + ["zip task 崩溃: {}","Zip task crashed: {}"], + ["已停止(当前模式 {})","Stopped (current mode {})"], + ["已启动 http://{}:{}","Started http://{}:{}"], + ["分钟 后重置","m"], + ["后台保活已停用(","Background keepalive disabled ("], + [",需重新登录)@",", re-login required) @"], + ["后台保活临时失败(","Background keepalive temporarily failed ("], + ["代理已自动切号 →","Proxy auto-switched →"], + ["检测到封号:","Ban detected:"], + [",已自动切换",", auto-switched"], + ["(早期数据·无 account)","(legacy data without account)"], + ["+ 添加","+ Add"], + ["没有匹配「","No matches for “"], + ["已复制 ✓","Copied ✓"], + ["确认删除路由「","Delete route “"], + ["来源:","Source:"], + ["⚠️ 链接里携带的","⚠️ The link includes"], + ["未在内置白名单中,已忽略。账号仍可正常使用, 但额度查询不会自动进行。导入后可在账号详情中手动选 usage preset。","is not on the built-in allowlist and was ignored. The account remains usable, but quota will not be fetched automatically. Select a usage preset in account details after import."], + ["添加路由","Add route"], + ["同步到 Server 失败:","Failed to synchronize to Server:"], + ["已安装 (","Installed ("], + [",此操作将从所有 CLI 目录移除该 skill。",", this will remove the skill from all CLI directories."], + ["失败:","Failed:"], + [",远端账号数",", remote accounts:"], + ["✅ 已设为手机锚","✅ Set as phone anchor"], + ["❌ 绑定失败:","❌ Binding failed:"], + ["✅ 已解除手机锚绑定","✅ Phone anchor removed"], + ["❌ 解除失败:","❌ Unbinding failed:"], + ["这将尝试移除 Codex App 的安全隔离属性。 系统可能会弹窗要求输入密码以获得权限。是否继续?","This will attempt to remove Codex App quarantine attributes. System may prompt for password to gain permissions. Continue?"], + ["off=独立;server=Server 侧提供 API;client=本机,rt 旋转走 Server。 老 solo 模式已合并到 client(自动迁移:solo → client + 本机直连上游 + 本机管 current)。","off: standalone; server: expose API; client: local app with refresh-token rotation on Server. Legacy solo mode migrates to client with direct upstream and local current-account management."], + ["off(关闭)","off (disabled)"], + ["(未设置)","(not set)"], + ["优先尝试,建议填局域网 IP,如 http://192.168.2.14:18081","Tried first. Use a LAN IP, such as http://192.168.2.14:18081"], + ["主不通时自动切换,建议填 ZeroTier IP,如 http://172.26.96.198:18081","Used automatically if primary is unreachable. Use a ZeroTier IP, such as http://172.26.96.198:18081"], + ["磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号; 切到其他号时 disk 不动、proxy 出口照切(手机 bridge 不掉线)。 仅 ChatGPT 订阅号可绑定。","Disk ~/.codex/auth.json remains bound to this account for Codex phone remote access. Other accounts can still serve proxy requests without changing disk identity or disconnecting the phone bridge. Only ChatGPT subscription accounts can be anchors."], + ["· 代理出口","· Proxy account"], + ["Vs 上月 $","Vs Last Month $"], + ["身份在线(手机 bridge 不掉线), 代理出口已切到","remains signed in for the phone bridge; proxy traffic now uses"], + ["(代理正在注入当前激活号的 token,","(the proxy injects the active account token,"], + ["; 关闭代理后 codex 会读到这个号)","; after stopping the proxy, Codex reads this account)"], + ["⚠️ 失效","⚠️ Invalid"], + ["💰 额度","💰 Credits"], + ["代理已启动","Proxy started"], + ["代理已停止","Proxy stopped"], + ["(新终端窗口生效)","(effective in new terminal windows)"], + ["auto:代理开启时自动走热切(不写 auth.json,不杀进程,仅刷 store.current)。 代理关闭时回退到冷切。cold:无论如何都冷切。","auto: use hot switching when the proxy is running, updating store.current without restarting processes. Otherwise use cold switching. cold: always cold switch."], + ["。 同邮箱已存在的账号会跳过,不覆盖现有 token。",". Existing accounts with the same email are skipped without overwriting tokens."], + ["(同名)","(same name)"], + ["新增账号详情(","New account details ("], + ["⚠ 仅 RT,首次请求自动 refresh","⚠ Refresh token only; automatically refreshed on first request"], + ["), 绕过 Codex 手机验证。支持单个对象、对象数组、或者嵌套容器,会自动递归识别。","), bypassing Codex phone verification. Supports one object, arrays or nested containers with recursive detection."], + ["导入账号详情(","Imported account details ("], + ["(id_token 合成)","(synthetic id_token)"], + ["失败项(","Failed entries ("], + ["glm_zhipu (GLM 自家 quota 接口)","glm_zhipu (GLM quota endpoint)"], + ["kimi_coding (Kimi 编程套餐 5H / 7D)","kimi_coding (Kimi Code 5H / 7D)"], + ["mimo_token_plan (MiMo 控制台 Cookie)","mimo_token_plan (MiMo console Cookie)"], + ["stepfun_plan (StepFun 控制台 Oasis-Token)","stepfun_plan (StepFun console Oasis-Token)"], + ["Oasis-Token=...(或直接粘贴 token)","Oasis-Token=... (or paste the token directly)"], + ["将从本地官方 Codex 的登录状态 (`auth.json`) 中提取认证信息。","Will extract authentication information from local official Codex login status (`auth.json`)."], + ["deep link 添加。","Add via deep link."], + ["(自定义 base URL)","(custom base URL)"], + ["kimi_coding · Kimi 编程套餐 5H / 7D","kimi_coding · Kimi Code 5H / 7D"], + ["mimo_token_plan · 需 Cookie","mimo_token_plan · Cookie required"], + ["stepfun_plan · Step Plan 控制台 Token","stepfun_plan · Step Plan console token"], + ["· 已过期","· Expired"], + ["· 今天","· Today"], + ["账号还有","Account has"], + [",点击修改","remaining; click to edit"], + ["(消耗了到期","(consumed the entry expiring on"], + ["的那条)",")"], + ["[AutoRefresh] 触发","[AutoRefresh] Triggered"], + ["个账号 reset 后自动刷新:","accounts refreshed after reset:"], + [",已刷新额度",", quota refreshed"], + ["推送 Server 成功(","Server push succeeded ("], + ["Google 切号失败:","Google account switch failed:"], + ["设为其模型的当前账号(Codex / Google 不变)","Set as current for its models (Codex / Google unchanged)"], + ["登录已失效:refresh_token 已过期或被撤销,请重新登录","Login expired: refresh_token expired or revoked. Sign in again"], + ["刷新失败:","Refresh failed:"], + ["📱 手机锚","📱 Phone anchor"], + ["🔄 次数未知","🔄 Count unknown"], + ["⚡ 当前已被限流(额度桶为 0)——现在用一次主动重置回收最大,点击查看明细","⚡ Currently rate-limited (quota bucket is zero). A reset now recovers the most quota; click for details"], + ["🚀 启动 codex","🚀 Start Codex"], + ["周期保鲜最近错误:","Latest window-priming error:"], + ["🌿 周期保鲜 ·","🌿 Window priming ·"], + ["🌿 保鲜已关 ·","🌿 Priming off ·"], + ["最近尝试:","Last attempt:"], + ["最近成功:","Last success:"], + ["最近结果:","Last result:"], + ["主动重置 ·","Manual reset ·"], + ["拉取明细失败:","Failed to fetch details:"], + [",区别仅到期时间;",", differing only in expiry;"], + [",也可粘贴包含它的 Cookie header。",", or paste a Cookie header containing it."], + [":每次切号前后强制抓 quota 写 `~/.codex-switcher/quota-snapshots.jsonl`。同一窗口内任意两次快照的 Δused_pct 配合期间代理 tokens → 推出该 Plan 总容量(capacity = Δtokens / Δpct × 100)。",": capture quota before and after each account switch in ~/.codex-switcher/quota-snapshots.jsonl. Token usage between snapshots and the change in used percentage estimate plan capacity: capacity = tokens / percentage change × 100."], + ["5h 周期(","5h cycles ("], + ["周周期(","Weekly cycles ("], + ["每格显示「","Each cell shows “"], + ["= `实测累加 ÷ snapshot used_pct × 100`,用快照里 used_pct 最大那个算(量化误差最小)。`?%` 标记的 used_pct 偏小,估算误差大。","= measured total / snapshot used_pct × 100, using the snapshot with the highest used_pct to minimize rounding error. ?% indicates low used_pct and higher estimation error."], + ["🔴 = 窗口内触发过限额切号 —— 此时实测累加 ≈ Plan 实际窗口配额。","🔴 A quota-driven switch occurred in this window; the measured total approximates the actual window quota."], + ["点开账号看历史周期,点开周期看 session 明细。","Expand an account for historical cycles and a cycle for session details."], + ["切号日志 (","Switch Log ("], + ["后台任务日志 (","Background Task Log ("], + ["% · 窗口","% · Window"], + ["Token Plan 订阅(tp-key;配额用控制台 Cookie)","Token Plan subscription (tp-key; quota uses console Cookie)"], + ["手动填 base_url;余额自动探测","Enter base_url manually; balance is detected automatically"], + ["模型","Model"], + ["最后更新:","Last updated:"], + ["奖励","Reward"], + ["目标账号异常:","Target account error:"], + ["还没有路由规则。点","No routes yet. Click"], + ["」的规则。","” rule."], + ["未知账号(已删除? id:","Unknown account (deleted? ID:"], + ["当前活跃会话","Active session"], + ["授权链接","Authorization link"], + ["以确认:","to confirm:"], + ["生成","Generate"], + ["心跳时自动把本机 current 对齐到 Server 的 current。 Server 不可达会静默跳过,保持本机现状。","On each heartbeat, align local current with Server current. If Server is unreachable, keep local state."], + ["上游模型额度:","Upstream model quota:"], + [";窗口明细未获取","; window details unavailable"], + ["代理已自动切换到账号:","Proxy auto-switched to account:"], + ["写入","Write"], + ["goals = true 到 ~/.codex/config.toml。","goals = true to ~/.codex/config.toml."], + ["(codex_credentials zip / 单 .json)、","(codex_credentials zip / single .json),"], + ["个账号","accounts"], + ["失效(约 30 天)后账号会变成不可用, 需要重新粘贴一次新 session。Plus 账号能正常调用模型,Free 账号即使导入也无 API 权限。","When the session expires (about 30 days), the account becomes unusable and needs a fresh session. Plus accounts can call models; imported Free accounts lack API access."], + [")粘贴到下方:",") below:"], + ["选一个","Choose a"], + [",base URL 自动填好,下一步只用粘 API Key。\n 需要在多家 Coding Plan 之间切换额度的话,可以把同一个服务添加多次(用账号名区分)。\n 也支持","; the base URL is filled automatically, so you only need the API key next. Add the same provider multiple times with different names to switch between subscription accounts. Also supports"], + ["切换服务","Change provider"], + ["前缀","Prefix"], + ["API 地址(可修改)","API URL (editable)"], + ["默认模型 ID(可修改)","Default model ID (editable)"], + ["余额查询","Balance query"], + ["额度凭证","Quota credentials"], + ["填写凭据","Enter credentials"], + ["← 返回选择","← Back to providers"], + ["设为这些模型的当前号:","Set as current for these models:"], + ["(不影响 Codex / Google)","(Codex / Google unchanged)"], + ["(通常最早到期优先,客户端无法指定)。","(usually earliest expiry first; clients cannot choose)."], + ["消耗 1 次会把当前已耗尽的 5H / 周限额窗口立刻清零,此操作不可撤销。若额度还没用到上限,上游会返回「无可重置」且","One reset clears currently exhausted 5H / weekly windows immediately. This cannot be undone. If no limit is exhausted, upstream returns no reset available and"], + ["。登录",". Sign in to"], + ["人","people"], + ["请检查邮箱格式和本次可发送人数(最多","Check email addresses and the current invitation limit (up to"], + ["个)。",")."], + ["上游已创建","Upstream created"], + ["条邀请","invitations"], + ["本次奖励:","Reward for this batch:"], + ["未成功:","Unsuccessful:"], + ["日","Day"], + ["周","Week"], + ["月","Month"], + ["是去掉异常值后最稳的估计。Δpct<3% 的样本被丢弃(used_pct 是整数,量化误差会失真)。","is the most stable estimate after removing outliers. Samples with percentage change below 3% are discarded because integer percentages amplify rounding errors."], + ["= token-history.jsonl 在该窗口内的精确求和(不平均、不打折,无论中间切号几次)。","= exact sum from token-history.jsonl for this window, without averaging or discounts, regardless of account switches."], + ["条)",")"], + ["无数据","No data"], + ["首次 → 末次","First → Last"], + ["直接切换将","Switching will"], + ["覆盖","overwrite"], + ["官方插件中的当前登录状态,且无法找回这些未同步的更新。","the current login state in the official plugin; unsynced updates will be lost."], + ["月之暗面(Kimi)","Moonshot (Kimi)"], + ["编程套餐:5H / 7D 额度;使用 Kimi Code Key,模型按会员权限选择","Coding subscription: 5H / 7D quota. Use a Kimi Code key; model access depends on your plan"], + ["智谱","Zhipu"], + ["开放平台 API;OpenAI 兼容,支持模型映射","Platform API; OpenAI compatible with model mapping"], + ["GLM Coding Plan(codex /v1/responses ↔ /chat/completions 翻译,内置)","GLM Coding Plan (built-in Codex Responses ↔ Chat Completions translation)"], + ["小米","Xiaomi"], + ["MiMo 按量付费(sk-key;非 Token Plan,无配额限制按用量计费)","MiMo pay-as-you-go (sk-key, not Token Plan; billed by usage)"], + ["PinCC / PackyCode / AICodeMirror / 自建 CLIProxyAPI 等;余额自动探测","PinCC / PackyCode / AICodeMirror / self-hosted CLIProxyAPI; automatic balance detection"], + ["原生 Responses API;支持官网或自有中转,API 地址和模型 ID 均可修改","Native Responses API; official or relay endpoints with editable API URL and model IDs"], + ["开放平台 API,原生 Responses;使用平台 API Key,非 Kimi Code 订阅密钥","Platform API with native Responses; use a platform API key, not a Kimi Code subscription key"], + ["按量付费 / 订阅服务;OpenAI Chat 兼容","Pay-as-you-go / subscription; OpenAI Chat compatible"], + ["阿里云","Alibaba Cloud"], + ["百炼开放平台;OpenAI 兼容模式","Bailian platform; OpenAI compatibility mode"], + ["通义","Tongyi"], + ["字节跳动","ByteDance"], + ["火山方舟 (Coding Plan / Agent Plan / 按量付费);model 字段需用户填实际 endpoint id","Volcengine Ark (Coding Plan / Agent Plan / pay-as-you-go); enter the actual endpoint ID as model"], + ["火","V"], + ["腾讯","Tencent"], + ["腾讯混元 (Token Plan / TokenHub 按量);OpenAI Chat 兼容","Tencent Hunyuan (Token Plan / TokenHub pay-as-you-go); OpenAI Chat compatible"], + ["混","H"], + ["百度","Baidu"], + ["百度千帆 / 文心一言 ERNIE(按量付费 / 中国订阅)","Baidu Qianfan / ERNIE (pay-as-you-go / China subscriptions)"], + ["千","Q"], + ["优刻得(UCloud)","UCloud"], + ["UCloud Modelverse (Coding Plan + 按量付费 国内/海外);多模型聚合","UCloud Modelverse (Coding Plan and domestic/international pay-as-you-go); multi-model API"], + ["Fireworks AI 海外高速推理(按量 / Fire Pass 国际订阅);OpenAI Chat 兼容","Fireworks AI fast inference (pay-as-you-go / Fire Pass); OpenAI Chat compatible"], + ["阶跃星辰 Step Plan","StepFun Step Plan"], + ["Step Plan 订阅;模型直接读取 /models,额度可用控制台 Oasis-Token 查询","Step Plan subscription; models loaded from /models, quota queried with console Oasis-Token"], + ["阶跃星辰","StepFun"], + ["阶跃星辰 step 系列 (按量付费 / 中国订阅 / 国际订阅)","StepFun step models (pay-as-you-go / China and international subscriptions)"], + ["多模型聚合 API;可配置厂商模型 ID","Multi-model API; configurable provider model IDs"], + ["Aiberm 全球聚合 API(OpenAI/Anthropic 双协议,模型按 token group 动态)","Aiberm global API (OpenAI/Anthropic protocols; models depend on token group)"], + ["神马中转 API 全球按量付费聚合(OpenAI/Anthropic 双协议)","Shenma global pay-as-you-go API (OpenAI/Anthropic protocols)"], + ["神","S"], + ["魔搭(阿里云)","ModelScope (Alibaba Cloud)"], + ["阿里魔搭 ModelScope 按量付费;OpenAI Chat 兼容(Qwen 系列为主)","Alibaba ModelScope pay-as-you-go; OpenAI Chat compatible, primarily Qwen models"], + ["魔","M"], + ["Ollama 本地推理(localhost:11434,需先 ollama pull 模型);API Key 任意填一个非空字符串即可","Local Ollama inference (localhost:11434; run ollama pull first). Any nonempty API key is accepted"], + ["● Proxy ON","● Proxy ON"], + ["● Proxy OFF","● Proxy OFF"], + ["📋 Dashboard","📋 Dashboard"], + ["↻ Refresh","↻ Refresh"], + ["Base URL","Base URL"], + ["Base URL *","Base URL *"], + ["API Key","API Key"], + ["API Key (sk-... / tp-...) *","API Key (sk-... / tp-...) *"], + ["deep link","deep link"], + ["deep link。","deep link。"], + ["Plan","Plan"], + ["Email / Plan","Email / Plan"], + ["Session","Session"], + ["Token","Token"], + ["检查同步状态失败:","Sync status check failed:"], + ["[Frontend] 收到后台刷新通知,重新加载账号列表","[Frontend] Received background refresh notification, reloading account list"], + ["[Frontend] 收到设置更新通知,重新加载设置","[Frontend] Received settings update notification, reloading settings"], + ["同步 IDE 状态失败:","Sync IDE state failed:"], + ["加载设置失败:","Failed to load settings:"], + ["加载代理状态失败:","Failed to load proxy status:"], + ["次","times"], + ["glm_zhipu · GLM 自家 quota","glm_zhipu · GLM quota"], + ["是去掉异常值后最稳的估计。Δpct<3% 的样本被丢弃(used_pct 是整数,量化误差会失真)。","is the most stable estimate after removing outliers. Samples with percentage change below 3% are discarded due to integer-percent rounding error."], + ["心跳时自动把本机 current 对齐到 Server 的 current。","On each heartbeat, align local current with Server current."], + ["Server 不可达会静默跳过,保持本机现状。","If Server is unreachable, keep local state."], + ["失效(约 30 天)后账号会变成不可用,","When the session expires (about 30 days), the account becomes unusable."], + ["需要重新粘贴一次新 session。","Paste a fresh session to restore access."], + ["秒 后重置","s"], + ["⚠️ 登录状态冲突警告","⚠️ Session Conflict Warning"], + ["检测到官方 Codex 插件中存在未同步的 Token 更新。","Unsynced token update detected in official Codex plugin."], + ["当前的账号状态与官方文件不一致:","Current account state conflicts with official file:"], + ["直接切换将覆盖官方插件中的当前登录状态,且无法找回这些未同步的更新。","Switching directly overwrites the official extension login state and loses these unsynchronized updates."], + ["确认覆盖并切换","Overwrite & Switch"], + ["以 IDE 为准 (同步状态)","Sync with IDE"], + ["导出成功!","Export successful!"], + ["导出失败:","Export failed:"], + ["加载中...","Loading..."], + ["加载中…","Loading…"], + ["代理运行中","Proxy running"], + ["代理未启动","Proxy stopped"], + ["仪表盘","Dashboard"], + ["账号管理","Accounts"], + ["代理控制","Proxy Control"], + ["本地代理服务器","Local Proxy Server"], + ["路由","Routes"], + ["统计","Stats"], + ["缓存","Cache"], + ["设置","Settings"], + ["+ 登录账号","+ Sign in"], + ["+ 添加中转","+ Add relay"], + ["+ 添加路由","+ Add route"], + ["从 IDE 自动导入","Auto-imported from IDE"], + ["同步状态失败:","Synch failed:"], + ["切换检查失败:","Switch check failed:"], + ["保守切换也失败了:","Conservative switch also failed:"], + ["确认切换失败:","Confirm switch failed:"], + ["覆盖 ~/.codex/auth.json 失败:","Failed to overwrite ~/.codex/auth.json:"], + ["后台保活已停用","Background keepalive disabled"], + ["后台保活临时失败","Temporary keepalive failure"], + ["代理已自动切号","Proxy switched account automatically"], + ["检测到封号","Account ban detected"], + ["已自动切换","Switched automatically"], + ["当前账号","Current Account"], + ["取消","Cancel"], + ["关闭","Close"], + ["确认","Confirm"], + ["保存","Save"], + ["删除","Delete"], + ["刷新","Refresh"], + ["切换","Switch"], + ["导入","Import"], + ["导出","Export"], + ["添加","Add"], + ["移除","Remove"], + ["清空","Clear"], + ["复制","Copy"], + ["已复制","Copied"], + ["保存并刷新","Save and refresh"], + ["测试连接","Test connection"], + ["重启 HTTP 服务","Restart HTTP service"], + ["写入环境变量","Write Env Variable"], + ["环境变量","Environment Variables"], + ["手动启动","Manual Launch"], + ["复制命令到终端运行","Copy command to terminal to run"], + ["局域网客户端","LAN Client"], + ["局域网可用","LAN Available"], + ["允许局域网访问","Allow LAN Access"], + ["全局代理(CLI + App 全覆盖)","Global Proxy (CLI + App Full Coverage)"], + ["同时写入 ~/.zshrc、launchctl 和 ~/.codex/config.toml,终端 CLI 和 Codex App 均走代理","Write to ~/.zshrc, launchctl and ~/.codex/config.toml, both terminal CLI and Codex App use proxy"], + ["定时额度刷新","Scheduled Quota Refresh"], + ["自动刷新账号额度","Auto Refresh Account Quota"], + ["刷新间隔(分钟/账号)","Refresh Interval (minutes/account)"], + ["每个账号之间的刷新间隔","Interval between each account refresh"], + ["每轮刷新账号数","Accounts per Refresh Round"], + ["每轮循环刷新多少个账号","How many accounts to refresh per cycle"], + ["切号通知","Switch Notifications"], + ["macOS 系统通知","macOS System Notification"], + ["切号时在屏幕右上角弹出系统通知","Show system notification at top-right corner when switching accounts"], + ["对话内注入通知(实验性)","Inject Conversation Notification (Experimental)"], + ["切号后在 Codex 对话中插入一条切号提示消息。可能影响对话状态。","Insert a switch notification message in Codex conversation after switching. May affect conversation state."], + ["Codex 配置","Codex Config"], + ["Fast 模式","Fast Mode"], + ["更快推理速度,但消耗 2x 额度。","Faster inference speed, but consumes 2x quota."], + ["更快推理速度,但消耗 2x 额度。当前:已开启","Faster inference with 2× quota usage. Currently enabled"], + ["更快推理速度,但消耗 2x 额度。当前:已关闭","Faster inference with 2× quota usage. Currently disabled"], + ["当前:已开启","Current: Enabled"], + ["当前:已关闭","Current: Disabled"], + ["关闭 Fast","Disable Fast"], + ["开启 Fast","Enable Fast"], + ["Goals 实验特性","Experimental Goals feature"], + ["写入 [features] goals = true 到 ~/.codex/config.toml。","Write [features] goals = true to ~/.codex/config.toml."], + ["写入 [features] goals = true 到 ~/.codex/config.toml。当前:已开启","Write [features] goals = true to ~/.codex/config.toml. Currently enabled"], + ["写入 [features] goals = true 到 ~/.codex/config.toml。当前:已关闭","Write [features] goals = true to ~/.codex/config.toml. Currently disabled"], + ["写入 [features] goals = true 到 ~/.codex/config.toml","Write [features] goals = true to ~/.codex/config.toml"], + ["关闭 Goals","Disable Goals"], + ["开启 Goals","Enable Goals"], + ["进程管理","Process Management"], + ["终止所有 Codex 进程","Kill All Codex Processes"], + ["强制终止所有运行中的 codex 进程,用于切换代理模式后重启或排错","Force kill all running codex processes, used for restarting after proxy mode switch or troubleshooting"], + ["终止中...","Killing..."], + ["终止进程","Kill Processes"], + ["智能切号策略","Smart Switch Strategy"], + ["5h 配额预防性切号阈值","5h Quota Preventive Switch Threshold"], + ["剩余配额低于此百分比时提前切号(0 = 仅 429 触发,推荐 10)","Preemptively switch when remaining quota falls below this percentage (0 = only triggered by 429, recommended 10)"], + ["周配额预防性切号阈值","Weekly Quota Preventive Switch Threshold"], + ["剩余周配额低于此百分比时提前切号(0 = 仅 429 触发,推荐 5)","Preemptively switch when remaining weekly quota falls below this percentage (0 = only triggered by 429, recommended 5)"], + ["Free 账号保护线","Free Account Guard Line"], + ["Free 账号剩余配额低于此百分比时强制切号(0 = 不特殊处理,推荐 35)","Force switch when Free account remaining quota falls below this percentage (0 = no special handling, recommended 35)"], + ["智能切号允许 FREE 账号","Smart Switch Allow FREE Account"], + ["点击“切换下一个账号”时,是否允许自动寻找并切到 FREE 账号(默认优先付费账号)","When clicking \"Switch Next Account\", whether to allow auto finding and switching to FREE account (default prioritizes paid accounts)"], + ["SSE Bootstrap 嗅探窗口","SSE bootstrap detection window"], + ["字节上限","Byte limit"], + ["时间上限","Time limit"], + ["读到这么多字节还没见到内容事件就放行 codex(默认 32768,越大越能逮住慢上游的限额事件)","Forward to Codex after this many bytes without content (default 32768). Larger values detect delayed upstream rate-limit events"], + ["等首个内容事件最长等多久(默认 8000ms)。配合 SSE keep-alive 心跳,拉到 30000 也不会让 codex 那头超时。","Maximum wait for first content (default 8000 ms). SSE keepalive allows values up to 30000 ms without timing out Codex"], + ["界面外观","Appearance"], + ["界面配色","Theme"], + ["主界面颜色的色调风格","Main interface color tone style"], + ["经典蓝","Classic Blue"], + ["玛瑙绿","Agate Green"], + ["暗黑护眼","Midnight Dark"], + ["关于","About"], + ["多账号智能切换 + 本地代理 + 用量统计","Multi-account smart switching + local proxy + usage stats"], + ["翻译","Translation"], + ["感谢帮助应用支持更多语言的贡献者","Thanks to contributors who help support more languages"], + ["你好,","Hello,"], + ["用户","User"], + ["手机锚生效:","Phone anchor active:"], + ["手机锚","Phone anchor"], + ["Codex.app 仍以","Codex.app remains signed in as"], + ["身份在线(手机 bridge 不掉线),","for uninterrupted phone bridge access,"], + ["代理出口已切到","proxy traffic now uses"], + ["磁盘 auth.json 落后:","Disk auth.json is behind:"], + ["登录状态不一致:","Session Mismatch:"], + ["停在","Still on"], + ["未知账号","Unknown account"], + ["代理正在注入当前激活号的 token,","The proxy injects the active account token,"], + ["不影响 codex 工作","Codex requests are unaffected"], + ["关闭代理后 codex 会读到这个号","After stopping the proxy, Codex reads this account"], + ["检测到 IDE 正在使用","Detected IDE using"], + ["同步磁盘","Synchronize disk"], + ["修正激活状态","Fix Active State"], + ["立即导入该账号","Import This Account"], + ["授权已失效,请删除后重新登录","Auth expired, please delete and re-login"], + ["失效","Invalid"], + ["与 ~/.codex/auth.json 身份不匹配","does not match ~/.codex/auth.json identity"], + ["IDE 当前用:","IDE currently uses:"], + ["用此账号覆盖 IDE","Overwrite IDE with this account"], + ["改用 IDE 当前","Use current IDE account"], + ["导入 IDE 当前","Import current IDE account"], + ["切换账号","Switch Account"], + ["暂无账号","No Account"], + ["最佳账号推荐","Best Account Recommendation"], + ["推荐账号","Recommended Account"], + ["暂无推荐","No Recommendations"], + ["一键切换最佳","Switch to Best"], + ["查看所有账号","View All Accounts"], + ["导出账号数据","Export Account Data"], + ["账号总数","Total Accounts"], + ["账号信息","Account Info"], + ["账号名称","Account name"], + ["搜索邮箱...","Search email..."], + ["关闭自动重载 IDE","Disable automatic IDE reload"], + ["开启自动重载 IDE","Enable automatic IDE reload"], + ["刷新所有账号配额","Refresh all account quotas"], + ["共 ","Total "], + ["点击复制账号","Click to copy account"], + ["该账号已被 OpenAI 封禁","This account has been banned by OpenAI"], + ["封号","Banned"], + ["登录已失效,可能是 refresh_token 过期、被撤销或会话在其他设备结束","Login expired: refresh_token may have expired, been revoked or ended on another device"], + ["需重新登录","Sign in again"], + ["该账号 Token 已过期或失效","This account token has expired or is invalid"], + ["过期","Expired"], + ["主动重置次数(剩余 0 次,无法重置)","Manual resets (0 remaining; reset unavailable)"], + ["主动重置次数","Manual resets"], + ["重置中","Resetting"], + ["该账号当前没有可用的主动重置次数。","This account has no manual resets available."], + ["消耗 1 次会把当前已耗尽的 5H / 周限额窗口立刻清零,此操作不可撤销。若额度还没用到上限,上游会返回「无可重置」且不扣次数。","One reset immediately clears exhausted 5H / weekly windows and cannot be undone. If no limit is exhausted, upstream reports no reset available and does not consume a reset."], + ["仍可直接消耗 1 次","You can still consume one reset directly"], + ["消耗哪条由服务端决定","The server chooses which reset credit is consumed"], + ["将被消耗","Will be consumed"], + ["立即重置","Reset now"], + ["已重置","Reset"], + ["当前没有可重置的限额","No exhausted limits to reset"], + ["没有可用的主动重置次数了","No manual resets remain"], + ["该重置请求已经兑现过了","This reset request has already been redeemed"], + ["请求过于频繁","Too many requests"], + ["Token 已失效,需要重新登录","Token invalid; sign in again"], + ["账号已被封禁","Account banned"], + ["账号已到期","Account expired"], + ["账号今天到期,点击修改","Account expires today; click to edit"], + ["点击设置账号到期日","Click to set account expiry"], + ["日期格式异常,点击修正","Invalid date format; click to correct"], + ["日期内容异常,点击修正","Invalid date value; click to correct"], + ["近 7 天","Last 7 days"], + ["清除日期","Clear date"], + ["账号到期日","Account expiry"], + ["到期日期","Expiry date"], + ["编辑备注","Edit note"], + ["发送 Codex 邀请","Send Codex invitation"], + ["发送邀请","Send invitation"], + ["发送中…","Sending…"], + ["请输入 skill 名称","Please enter skill name"], + ["请至少填写 1 个邀请邮箱","Enter at least one invitation email"], + ["当前套餐不支持发邀请(free 号没有邀请权限)","This plan does not support invitations (Free accounts cannot invite)"], + ["已发送,邮件已投递到对方邮箱。","Sent. The email was delivered to the recipient."], + ["邀请未成功","Invitation unsuccessful"], + ["该邮箱无法被邀请(通常是已注册过 ChatGPT 的老用户,推荐奖励只对新邮箱有效)","This email cannot be invited, usually because it already has a ChatGPT account. Referral rewards apply to new emails"], + ["已经给该邮箱发过邀请了(同一邮箱不能重复邀请)","This email has already been invited; duplicate invitations are not allowed"], + ["邀请链接","Invitation link"], + ["有效;同一邮箱只能邀一次。","valid; each email can only be invited once."], + ["邮箱列表(每行一个)","Email list (one per line)"], + ["邮箱 OTP 批量自动授权","Batch authorization with email OTP"], + ["每个邮箱 OTP 超时(秒)","OTP timeout per email (seconds)"], + ["开始批量授权","Start batch authorization"], + ["授权中…","Authorizing…"], + ["重试失败","Retry failed"], + ["官方 OAuth 授权","Official OAuth Authorization"], + ["OpenAI 登录 (推荐)","OpenAI Login (Recommended)"], + ["直接通过 OpenAI 官方渠道登录。支持令牌自动续期,多账号切换更稳定,无需再手动更新 `auth.json`。","Log in directly through OpenAI official channel. Supports token automatic renewal, multi-account switching is more stable, no need to manually update `auth.json`."], + ["授权将在你系统的默认浏览器中完成,安全可信。","Authorization will complete in your system default browser, safe and trusted."], + ["复制授权链接(指定浏览器登录)","Copy authorization link (choose browser)"], + ["浏览器没跳回来?手动粘贴回调链接","Browser did not return? Paste the callback URL manually"], + ["从浏览器地址栏复制完整 URL(包含","Copy the full URL from the browser address bar (including"], + ["将从本地官方 Codex 的登录状态","Authentication will be extracted from the local official Codex login"], + ["中提取认证信息。","."], + ["从官方导入","Import from Official"], + ["批量导入文件","Batch import files"], + ["自动识别格式,可一次选多个文件:","Formats are detected automatically. Select multiple files:"], + ["codex-switcher 原生 accounts.json","Native codex-switcher accounts.json"], + ["chatgpt.com 网页登录的 session JSON","Session JSON from chatgpt.com web login"], + ["Web session 没有","Web sessions have no"], + ["绕过 Codex 手机验证。支持单个对象、对象数组、或者嵌套容器,会自动递归识别。","Bypasses Codex phone verification. Supports individual objects, arrays and nested containers with recursive detection."], + ["解析","Parse"], + ["新增账号详情","New account details"], + ["导入账号详情","Imported account details"], + ["失败项","Failed entries"], + ["失败","Failed"], + ["新增","Added"], + ["同名","Same name"], + ["同邮箱已存在的账号会跳过,不覆盖现有 token。","Existing accounts with the same email are skipped without overwriting tokens."], + ["添加中转 (Coding Plan / 通用 Responses 中转)","Add relay (Coding Plan / native Responses)"], + ["选择中转服务","Choose provider"], + ["选预设 · 填 Key","Choose preset · Enter key"], + ["选完进入下一步,base URL 已自动填好","Continue to the next step with the base URL filled automatically"], + ["选择目标账号","Choose target account"], + ["选择会话","Choose session"], + ["从最近会话挑选","Choose a recent session"], + ["手动输入 ID","Enter ID manually"], + ["绑定当前活跃会话","Bind active session"], + ["加载最近会话…","Loading recent sessions…"], + ["最近没有活跃会话","No recent active sessions"], + ["粘贴 session_id(UUID 格式,36 字符)","Paste session_id (36-character UUID)"], + ["看起来不像 UUID。请检查长度是否为 36 字符、包含 4 个连字符。","This does not look like a UUID. Check for 36 characters and four hyphens"], + ["把会话钉到账号","Pin session to account"], + ["指定 codex 会话强制使用某个账号,绕过全局自动切号。","Force a Codex session to use a specific account, bypassing global automatic switching."], + ["当前绑定账号","Bound account"], + ["✓ 当前绑定","✓ Currently bound"], + ["条规则,启用","rules; enabled:"], + ["还没有路由规则。","No routing rules yet."], + ["没有匹配的最近会话。试试调整搜索词或切换到「手动输入 ID」。","No matching recent sessions. Adjust the search or enter an ID manually."], + ["没有匹配","No matches"], + ["删除后该 session 将恢复全局自动切号。","Deleting restores global automatic account switching for this session."], + ["确认删除路由","Confirm route deletion"], + ["目标账号已被删除","Target account was deleted"], + ["Skills","Skills"], + ["Skill","Skill"], + ["skill","skill"], + ["Usage Monitor","Usage Monitor"], + ["Proxy ON","Proxy ON"], + ["Proxy OFF","Proxy OFF"], + ["SESSION","SESSION"], + ["WEEKLY","WEEKLY"], + ["COST USAGE","COST USAGE"], + ["TOKEN USAGE","TOKEN USAGE"], + ["Remaining","Remaining"], + ["Spent","Spent"], + ["Tokens","Tokens"], + ["HEALTHY","HEALTHY"], + ["WARNING","WARNING"], + ["CRITICAL","CRITICAL"], + ["Resets in","Resets in"], + ["In","In"], + ["Out","Out"], + ["Vs 上月","Vs last month"], + ["手机锚 = 当前号","Phone anchor = current account"], + ["代理出口 ","Proxy account "], + ["需重登","Sign in again"], + ["Google 当前","Current Google account"], + ["模型 当前","Current for models"], + ["部分模型当前","Current for some models"], + ["Kimi 当前","Current Kimi account"], + ["DeepSeek 当前","Current DeepSeek account"], + ["当前模型:","Current models:"], + ["Token 配额","Token quota"], + ["Spark 周","Spark weekly"], + ["三方","Third-party"], + ["未获取数据","No data"], + ["次数未知","Count unknown"], + ["上游未返回重置次数,不代表次数已清空。点击查询银行明细。","Upstream did not return a reset count; it is not necessarily zero. Click to query bank details"], + ["当前已被限流(额度桶为 0)——现在用一次主动重置回收最大,点击查看明细","Currently rate-limited (quota bucket is zero). A reset now recovers the most quota; click for details"], + ["点击查看所有主动重置次数(含各自到期时间),再消耗一次重置限额窗口","View all reset credits and their expiry before consuming one to reset quota windows"], + ["登录账号 (OpenAI / Google / 导入)","Sign in (OpenAI / Google / import)"], + ["刷新 Codex 当前账号额度","Refresh current Codex account quota"], + ["刷新当前列表额度","Refresh current list quotas"], + ["ACCOUNT INVITE","ACCOUNT INVITE"], + ["OFFER","OFFER"], + ["RECIPIENTS","RECIPIENTS"], + ["RESULT","RESULT"], + ["HISTORY","HISTORY"], + ["Session 导入","Session import"], + ["选择文件并导入","Select files and import"], + ["解析并导入","Parse and import"], + ["导入中…","Importing…"], + ["跳过","Skipped"], + ["仅 RT,首次请求自动 refresh","Refresh token only; refreshed automatically on first request"], + ["✓ ready","✓ ready"], + ["含 refresh_token","Includes refresh_token"], + ["无 refresh_token,到期后需重新导入","No refresh_token; re-import after expiry"], + ["id_token 合成","Synthetic id_token"], + ["注意:","Note:"], + ["Plus 账号能正常调用模型,Free 账号即使导入也无 API 权限。","Plus accounts can call models. Imported Free accounts lack API access."], + ["粘贴","Paste"], + ["网页登录的 session JSON","web-login session JSON"], + ["连接 Google 账号","Connect Google account"], + ["等待浏览器授权…","Waiting for browser authorization…"], + ["授权后 Gemini 模型通过 Codex Switcher 原生路由;不会修改 Codex 的 OpenAI 登录身份。","After authorization, Gemini models use native Codex Switcher routing without changing the OpenAI login identity."], + ["不打开默认浏览器,把 Google 授权链接复制到剪贴板","Copy the Google authorization link without opening the default browser"], + ["导入中转站账号","Import relay account"], + ["链接里携带的","The link includes"], + ["未在内置白名单中,已忽略。账号仍可正常使用,","which is not on the built-in allowlist and was ignored. The account remains usable,"], + ["但额度查询不会自动进行。导入后可在账号详情中手动选 usage preset。","but quota will not be fetched automatically. Select a usage preset in account details after import."], + ["导入失败:","Import failed:"], + ["导入中转站","Import relay"], + ["预设","Preset"], + ["选预设自动填 base_url,贴 API Key 即可。也支持","Choose a preset to fill base_url automatically, then paste the API key. Also supports"], + ["余额查询策略","Balance query strategy"], + ["不拉取","Do not fetch"], + ["登录 platform.stepfun.com 后复制 Oasis-Token,也可粘贴 Cookie header","Sign in to platform.stepfun.com and copy Oasis-Token or a Cookie header"], + ["登录 platform.xiaomimimo.com 后,从 Network 复制 Cookie header","Sign in to platform.xiaomimimo.com and copy the Cookie header from Network"], + ["该凭证只用于查询 Step Plan 额度,不会参与模型请求;实际调用仍使用上面的 Step API Key。","This credential is only used to query Step Plan quota. Model requests still use the Step API key above."], + ["这里的 Cookie 只用于查询 Token Plan 用量,不会参与模型请求。实际调用仍使用上面的 tp-key。","This Cookie is only used to query Token Plan usage. Model requests still use the tp-key above."], + ["上游协议","Upstream protocol"], + ["中转站讲什么 wire format","Upstream wire format"], + ["模型兜底","Fallback model"], + ["客户端发的 model 没命中映射时,统一替换成这个","Used when the client model does not match a mapping"], + ["模型映射表","Model mapping"], + ["每行","One per line:"], + ["客户端model=中转站model","client_model=relay_model"], + ["如 glm-5.1(留空 = 透传不替换)","For example glm-5.1 (empty = pass through)"], + ["修改 StepFun 额度凭证","Edit StepFun quota credential"], + ["修改 MiMo 配额 Cookie","Edit MiMo quota Cookie"], + ["账号:","Account:"], + ["后复制","and copy"], + ["后,从 Network 请求里复制","and copy from Network requests"], + ["也可粘贴包含它的 Cookie header。","Or paste a Cookie header containing it."], + ["仓库","Repos"], + ["发现","Discover"], + ["已安装","Installed"], + ["未链接","Unlinked"], + ["已链接","Linked"], + ["暂无已安装的 skill","No installed skills"], + ["无描述","No description"], + ["正在扫描 GitHub 仓库...","Scanning GitHub repositories..."], + ["点击\"刷新\"从仓库发现 skill","Click \"Refresh\" to discover skills from repositories"], + ["确认卸载","Confirm Uninstall"], + ["即将卸载","About to uninstall"], + ["此操作将从所有 CLI 目录移除该 skill。","This removes the skill from all CLI directories."], + ["以确认:","to confirm:"], + ["卸载","Uninstall"], + ["发现失败:","Discovery failed:"], + ["安装失败:","Install failed:"], + ["卸载失败:","Uninstall failed:"], + ["无法读取 SKILL.md","Cannot read SKILL.md"], + ["操作失败:","Operation failed:"], + ["同步完成","Sync complete"], + ["同步失败:","Sync failed:"], + ["同步中...","Synchronizing..."], + ["同步到 Server","Synchronize to Server"], + ["将本机 SSOT 下所有 skill 推送到 Server(按黑名单跳过)","Push all local SSOT skills to Server, excluding blacklisted skills"], + ["全量同步","Sync All"], + ["搜索 skill...","Search skills..."], + ["推送","Push"], + ["已断开链接","unlinked from Skills"], + ["已链接到 Skills","linked to Skills"], + ["已卸载","Uninstalled"], + ["自动导入","Auto imported"], + ["个新 skill","new skills"], + [" 个 skill"," skills"], + ["已添加","Added"], + ["套餐待同步","Plan awaiting synchronization"], + ["到期,点击修改","expiry; click to edit"], + ["今天到期","Expires today"], + ["今天","Today"], + ["还有 ","Expires in "], + [" 天到期,点击修改"," days; click to edit"], + ["到期日 ","Expiry date "], + ["点击修改","Click to edit"], + ["本地已保存,但同步 Server 失败:","Saved locally, but Server synchronization failed:"], + ["已打开 codex 终端","Codex terminal opened"], + ["启动失败:","Start failed:"], + ["重置失败:","Reset failed:"], + ["消耗了到期","Consumed the credit expiring on"], + ["的那条",""], + ["触发 ","Triggered "], + ["个账号 reset 后自动刷新","accounts refreshed automatically after reset"], + ["刷新失败:","Refresh failed:"], + ["天 ","days "], + ["时 ","hours "], + ["分","m"], + ["重试中","Retrying"], + ["合并到同邮箱旧账号","Merged into existing account with same email"], + ["更新","Updated"], + ["已刷新额度","Quota refreshed"], + ["推送 Server 成功","Push to Server succeeded"], + ["推送失败:","Push failed:"], + ["Google 当前账号已切换为","Current Google account switched to"], + ["Google 切号失败:","Google account switch failed:"], + ["已将 ","Set "], + ["设为其模型的当前账号","as current account for its models"], + ["Codex / Google 不变","Codex / Google unchanged"], + ["模型切号失败:","Model account switch failed:"], + ["登录已失效:","Login expired:"], + ["已过期或被撤销","expired or revoked"], + ["请重新登录","Sign in again"], + ["刷新令牌","Refresh token"], + ["保存额度凭证失败:","Failed to save quota credential:"], + ["点击修改额度查询凭证","Click to edit quota-query credentials"], + ["额度余额 ","Credit balance "], + ["来自 /wham/usage 的 credits.balance","credits.balance from /wham/usage"], + ["请刷新该账号额度以查询 credits.balance","Refresh this account quota to query credits.balance"], + ["IDE 自动重载仅支持 macOS,请手动重载 IDE","Automatic IDE reload is only supported on macOS. Reload the IDE manually"], + ["配额状态","Quota Status"], + ["同步/保活","Sync/Keepalive"], + ["操作","Actions"], + ["点击打开 ","Click to open "], + ["刷新模型额度","Refresh model quota"], + ["切换 Google 当前账号(不影响 Codex 当前账号)","Switch current Google account (Codex account unchanged)"], + ["邀请同事使用 ChatGPT 桌面版","Invite colleagues to ChatGPT desktop"], + ["邀请朋友使用 ChatGPT 桌面版","Invite friends to ChatGPT desktop"], + ["确定要永久删除账号","Are you sure you want to permanently delete account"], + ["吗?","?"], + ["此操作不可恢复,删除后有关该账号的本地授权信息将被清除。","This action cannot be undone. Local authorization information for this account will be cleared after deletion."], + ["ChatGPT / Codex 目前没有提供可靠的订阅到期日接口,因此这里由你手工维护。它不会修改登录 Token、不会当作额度重置时间,也不会触发自动切号。留空保存可清除日期。","ChatGPT / Codex currently provides no reliable subscription-expiry API, so this date is maintained manually. It does not change login tokens, quota-reset times or automatic switching. Save an empty value to clear it."], + ["保存中…","Saving…"], + ["系统默认自动管理全部订阅号,并以 /wham/usage 返回的 primary_window 实际时长判断 5H 或 7D,不绑定套餐名称。首次遇到无法确认是否激活的 100% 窗口时只发一次极小请求;之后按固定 reset_at 到点触发。这里可以单独关闭该账号,client/solo 模式由 Server 单端执行。","All subscription accounts are managed automatically. The primary_window duration from /wham/usage determines 5H or 7D independently of plan names. Unknown full windows receive one minimal probe; later triggers follow reset_at. Disable per account here; client/solo mode runs on Server only."], + ["当前可用次数无法确认,不代表已清空。请稍后重试查询。","Available reset count is unknown, not necessarily zero. Retry the query later."], + ["次,按到期时间排序(最早在前)。所有次数","resets, sorted by earliest expiry. All resets are"], + ["等价","equivalent"], + ["区别仅到期时间;","and differ only in expiry;"], + ["剩 ","Remaining "], + ["不扣次数","No reset consumed"], + ["重新查询","Query again"], + ["正在重置…","Resetting…"], + ["或直接粘贴 token","or paste the token directly"], + ["四段RT","Four-part refresh token"], + ["默认 / 上游懂 codex /v1/responses","Default / upstream supports Codex /v1/responses"], + ["StepFun 额度查询需要粘贴 platform.stepfun.com 的 Oasis-Token;如果暂时不查配额,请把余额查询策略改成“不拉取”。","StepFun quota queries require Oasis-Token from platform.stepfun.com. To skip quota queries, choose Do not fetch."], + ["MiMo 配额查询需要粘贴 platform.xiaomimimo.com 的 Cookie;如果暂时不查配额,请把余额查询策略改成“不拉取”。","MiMo quota queries require the Cookie from platform.xiaomimimo.com. To skip quota queries, choose Do not fetch."], + ["已获取授权码,正在交换令牌...","Authorization code received, exchanging for token..."], + ["授权成功!账号已添加。","Authorization successful! Account added."], + ["已获取 Google 授权码,正在验证账号和项目...","Google authorization code received; validating account and project..."], + ["Google Antigravity 账号已添加。","Google Antigravity account added."], + ["请输入账号名称","Please enter account name"], + ["正在启动官方浏览器授权...","Starting official browser authorization..."], + ["请在打开的浏览器窗口中完成 OpenAI 授权...","Please complete OpenAI authorization in the opened browser window..."], + ["授权链接已复制,请粘贴到目标浏览器完成授权,回调会自动回到本应用...","Authorization link copied. Paste into your chosen browser; the callback returns to this app..."], + ["自动复制失败。请在下方选择完整链接手动复制,或重试复制。","Automatic copying failed. Select the complete link below to copy manually, or retry."], + ["正在生成授权链接...","Generating authorization link..."], + ["正在启动 Google Antigravity 授权...","Starting Google Antigravity authorization..."], + ["请在浏览器中完成 Google 授权...","Complete Google authorization in your browser..."], + ["账号导入文件","Account import files"], + ["所有文件","All files"], + ["请粘贴 ChatGPT session JSON","Paste ChatGPT session JSON"], + ["已提交回调链接,正在交换令牌...","Callback submitted; exchanging tokens..."], + ["添加账号","Add Account"], + ["带 ","With "], + ["处理中...","Processing..."], + ["导入当前账号","Import Current Account"], + ["立即登录 OpenAI","Login with OpenAI"], + ["提交中...","Submitting..."], + ["开始授权","Start authorization"], + ["GLM 自家 quota 接口","GLM quota endpoint"], + ["Kimi 编程套餐 5H / 7D","Kimi Code 5H / 7D"], + ["MiMo 控制台 Cookie","MiMo console Cookie"], + ["StepFun 控制台 Oasis-Token","StepFun console Oasis-Token"], + ["StepFun 额度凭证","StepFun quota credential"], + ["基于 new-api / CLIProxyAPI / sub2api 的第三方中转,原生 /v1/responses","Third-party new-api / CLIProxyAPI / sub2api relay with native /v1/responses"], + ["厂商编程订阅,按服务支持选择 Responses 或 Chat Completions","Provider coding subscriptions using Responses or Chat Completions as supported"], + ["厂商按量付费 API(DeepSeek / Kimi / 通义 / OpenRouter 等)","Provider pay-as-you-go APIs (DeepSeek / Kimi / Qwen / OpenRouter)"], + ["手动填 base_url","Enter base_url manually"], + ["保存后直接读取 StepFun /models 返回的原生 step-* 模型,在 Codex 模型列表中选择。","After saving, native step-* models from StepFun /models appear in the Codex model selector."], + ["保存后可在 Codex 中选择此中转的模型,无需切换 ChatGPT 账号。","Select this relay model in Codex after saving without switching ChatGPT accounts."], + ["预设仅提供默认地址;使用自有中转时,请填写该中转的 API 地址和对应 Key。","Presets provide default URLs. For a custom relay, enter its API URL and matching key."], + ["填写此 API 实际支持的模型 ID","Enter a model ID supported by this API"], + ["会显示在 Codex 模型列表;高级设置可添加其他模型。","Appears in the Codex model selector; add more models in advanced settings."], + ["需 Cookie","Cookie required"], + ["Step Plan 控制台 Token","Step Plan console token"], + ["从 platform.stepfun.com Cookie 复制 Oasis-Token,或直接粘贴 token","Copy Oasis-Token from platform.stepfun.com Cookie, or paste the token directly"], + ["模型来源","Model source"], + ["高级设置(模型兜底 / 映射表)","Advanced settings (fallback model / mapping)"], + ["Step Plan 不使用模型映射;模型列表由官方 API 返回,保存后刷新 Codex 模型列表即可。","Step Plan uses models returned by the official API, not model mapping. Save and refresh Codex models."], + ["客户端发的 model 未命中映射时统一替换","Replace unmapped client models with this fallback"], + ["API Key 不能为空","API key cannot be empty"], + ["StepFun 额度查询需要粘贴 platform.stepfun.com 的 Oasis-Token;不查配额请把策略改成「不拉取」。","StepFun quota requires Oasis-Token from platform.stepfun.com. Choose Do not fetch to skip queries."], + ["MiMo 配额查询需要粘贴 platform.xiaomimimo.com 的 Cookie;不查配额请把策略改成「不拉取」。","MiMo quota requires Cookie from platform.xiaomimimo.com. Choose Do not fetch to skip queries."], + ["账号于 ","Account expiry: "], + ["本地已保存,但同步 Server 失败:","Saved locally, but Server synchronization failed:"], + ["启动失败:","Start failed:"], + ["重置失败:","Reset failed:"], + ["切号失败:","Account switch failed:"], + ["模型切号失败:","Model account switch failed:"], + ["登录已失效:","Login expired:"], + ["导入中...","Importing..."], + ["注意:","Note:"], + ["(带","(with"], + ["添加中转","Add relay"], + ["余额 ","Balance "], + ["✓ 已选","✓ Selected"], + ["一键使用 →","Use now →"], + ["没有账号。先去「账号管理」添加。","No accounts. Add one in Account Management first."], + ["例如:GLM 跑文档","For example: GLM for documentation"], + ["在路由列表里显示这个名字,方便识别。","Shown in the route list to help identify this route."], + ["准备就绪 — 点「添加路由」","Ready — click Add route"], + ["选好会话和账号即可添加","Choose a session and account to add"], + ["重置未知","Reset unknown"], + ["待刷新","Refresh pending"], + ["上游未提供重置时间","Upstream did not provide reset time"], + ["重置时间:","Reset time:"], + ["7D 额度已耗尽,请展开查看","7D quota exhausted; expand for details"], + ["7D耗尽","7D exhausted"], + ["暂无模型额度,点击刷新","No model quota yet; click Refresh"], + ["收起全部模型","Collapse all models"], + ["查看全部模型","View all models"], + ["全部模型额度","All model quotas"], + ["剩余额度 · 重置倒计时","Remaining quota · Reset countdown"], + ["默认优先显示 5H;5H 与 7D 同时生效,同组模型共享额度。未获取的窗口显示未知。","5H is shown first. Both 5H and 7D apply; models in the same group share quota. Unavailable windows show unknown."], + ["模型额度列表","Model quotas"], + ["早期数据·无 account","Legacy data without account"], + ["正在切换...","Switching..."], + ["新账号","New Account"], + ["复制中…","Copying…"], + ["复制此链接","Copy this link"], + ["端口已更新(重启代理后生效)","Port updated (effective after proxy restart)"], + ["保存失败:","Save failed:"], + ["新终端窗口生效","Applies in new terminal windows"], + ["启动代理","Start Proxy"], + ["关闭代理","Stop Proxy"], + ["复制命令到 PowerShell 运行","Copy and run in PowerShell"], + ["client 模式:本机不跑 oauth,改为从 Server /quotas 自动同步 cached_quota(约 5 分钟一轮)","Client mode does not perform local OAuth; cached_quota is synchronized from Server /quotas about every 5 minutes"], + ["按最后更新时间排序,自动循环刷新所有账号的配额数据","Sort by last update time, auto cycle refresh all account quota data"], + ["邀请同事加入工作区,奖励按官方活动规则发放","Invite colleagues to the workspace; rewards follow official campaign rules"], + ["邀请朋友加入,奖励按官方活动规则发放","Invite friends; rewards follow official campaign rules"], + ["关闭邀请窗口","Close invitation window"], + ["发邀账号","Inviting account"], + ["当前活动","Current campaign"], + ["活动范围","Campaign scope"], + ["个人账号","Personal account"], + ["工作区","Workspace"], + ["查询中…","Querying…"], + ["刷新资格","Refresh eligibility"], + ["正在查询活动资格…","Checking campaign eligibility…"], + ["每位符合条件的邀请奖励","Reward per eligible invitation"], + ["当前账号暂未开放","Not available for this account"], + ["当前账号暂未开放此邀请活动。","This invitation campaign is not available for this account."], + ["可发送邮箱","Allowed recipients"], + ["未提供","Not provided"], + ["接口未提供实际奖励金额,不能根据活动编号推断为 500 或 1000。","The API did not provide the reward amount; do not infer 500 or 1000 from the campaign ID."], + ["接口未提供实际奖励金额,不能根据活动编号推断奖励数额。","The API did not provide the reward amount; do not infer it from the campaign ID."], + ["活动奖励不等于当前余额;对方接受邀请并完成官方要求后才会到账。","Campaign rewards are not current balance. They arrive after recipients accept and meet official requirements."], + ["当前奖励名额为 0,已禁止发送邀请。","No reward slots remain; invitation sending is disabled."], + ["填写受邀邮箱","Enter recipient emails"], + ["奖励名额已用完","Reward slots exhausted"], + ["最多发送 ","Send up to "], + ["受邀邮箱","Recipient email"], + ["每行一个邮箱,也可用逗号或分号分隔","One email per line, or separate with commas or semicolons"], + ["我已确认收件人及奖励条件,发送邀请不代表奖励已到账。","I confirmed recipients and reward conditions. Sending an invitation does not mean rewards have arrived."], + ["发送结果","Send result"], + ["邀请记录已创建","Invitation records created"], + ["未返回已创建的邀请,请查询记录确认。","No created invitations returned. Check records to confirm."], + ["本次提交已结束。需要再次邀请时,请先核对记录,再重新打开此窗口。","This submission is complete. Check records before reopening to invite again."], + ["邀请记录","Invitation records"], + ["查询近 90 天","Query last 90 days"], + ["近 90 天没有邀请记录。","No invitation records in the last 90 days."], + ["上游未提供状态","Upstream did not provide status"], + ["加载更多","Load more"], + ["奖励到账以官方记录为准","Reward fulfillment follows official records"], + ["邀请额度","Invitation quota"], + ["刷新邀请额度","Refresh invitation quota"], + ["正在查询邀请资格…","Checking invitation eligibility…"], + ["每位奖励","Reward per recipient"], + ["上次数据已过期,请刷新额度","Previous data expired; refresh quota"], + ["剩余 ","Remaining "], + ["加载路由规则…","Loading routing rules…"], + ["删除路由","Delete route"], + ["点击复制完整 session_id","Click to copy complete session_id"], + ["✅ 设置已保存","✅ Settings saved"], + ["❌ 保存失败:","❌ Save failed:"], + ["生成失败:","Generation failed:"], + ["已切换到 ","Switched to "], + ["已与 Server 一致,无需切换","Already matches Server; no switch needed"], + ["使用 ","Using "], + ["远端账号数 ","Remote accounts "], + ["推送全部账号到 Server","Push all accounts to Server"], + ["已上传 ","Uploaded "], + ["从 Server 拉取全部账号","Pull all accounts from Server"], + ["已合并 ","Merged "], + ["从 Server 同步所有 token","Synchronize all tokens from Server"], + ["已写 auth.json","auth.json written"], + ["✅ 修复成功!\n\n现在请尝试重新打开 Codex App。","✅ Repair successful!\n\nTry reopening Codex App now."], + ["保存中...","Saving..."], + ["保存设置","Save Settings"], + ["client 模式:保活由 Server 负责,本机已强制关闭(避免双路刷新撞飞 refresh_token)","In client mode, Server handles keepalive. Local keepalive is disabled to prevent conflicting refresh-token rotation"], + ["Server 负责","Managed by Server"], + ["已开启","Enabled"], + ["已关闭","Disabled"], + ["执行中...","Running..."], + ["立即重启","Restart now"], + ["更换","Change"], + ["选择绑定账号","Choose anchor account"], + ["隔离属性修复仅适用于 macOS","Quarantine repair is only available on macOS"], + ["修复中...","Fixing..."], + ["立即修复","Fix Now"], + ["当前没有 ChatGPT 订阅号","No ChatGPT subscription accounts"], + ["没有匹配的账号","No matching accounts"], + ["扫描中...","Scanning..."], + ["个人活动","Personal campaign"], + ["工作区活动","Workspace campaign"], + ["个使用额度","usage credits"], + ["个工作区额度","workspace credits"], + ["次限额重置","quota resets"], + ["活动未提供奖励数额","Campaign did not provide reward amount"], + ["发送上限","Send limit"], + ["奖励名额","Reward slots"], + ["250 个使用额度","250 usage credits"], + ["可以 отправить email","Can send email"], + ["Codex CLI 通过代理连接 OpenAI,支持无中断自动切号和 429 智能重试","Codex CLI connects to OpenAI via proxy, supports seamless auto-switch and 429 smart retry"], + ["auto:代理开启时自动走热切(不写 auth.json,不杀进程,仅刷 store.current)。代理关闭时回退到冷切。cold:无论如何都冷切。","auto: hot switch while the proxy runs; update store.current without restarting processes. Otherwise cold switch. cold: always cold switch."], + ["代理开启时自动走热切","Hot switch while proxy is running"], + ["不写 auth.json,不杀进程,仅刷 store.current","Do not write auth.json or terminate processes; only update store.current"], + ["代理关闭时回退到冷切","Cold switch when proxy is stopped"], + ["无论如何都冷切","Always cold switch"], + ["开启(默认):current 是中转 / Coding Plan / 三方 API 时,遇到 401/429/quota 自动切到健康的订阅号,避免请求卡死。关闭后这类号出错会把错误透传给客户端,不偷换。","Enabled by default: when current is a relay / Coding Plan / third-party API, 401/429/quota errors switch to a healthy subscription. Disabled: errors pass to the client without changing accounts."], + ["关闭(默认):用订阅号时自动切号 / affinity 不会路由到中转 / Coding Plan / 三方 API,避免偷扣余额。开启后这类号跟订阅号同等参与轮询(量大但要确认你愿意花对应套餐的额度)。","Disabled by default: automatic switching and affinity do not select relays / Coding Plans / third-party APIs while using subscriptions. Enabling includes them in rotation and may consume their paid quota."], + ["开启后 HTTP 也跟 WebSocket 同路,本机直接打上游,不再经 Server 转发;access_token 仍从 Server 拉。适合 Server 出口不稳但本机出口稳的场景。","HTTP uses direct upstream like WebSocket rather than Server forwarding. Access tokens still come from Server. Useful when local connectivity is more reliable."], + ["开启后本机自己决定 current(不被 Server `/current` 反向同步覆盖),`~/.codex/auth.json` 也由本机自己写。等价于旧 solo 模式(已并入 client)。","Local app manages current without Server /current overwriting it, and writes ~/.codex/auth.json locally. Equivalent to legacy solo mode, now part of client mode."], + ["off=独立;server=Server 侧提供 API;client=本机,rt 旋转走 Server。","off: standalone; server: provide API; client: local app with refresh-token rotation on Server."], + ["老 solo 模式已合并到 client(自动迁移:solo → client + 本机直连上游 + 本机管 current)。","Legacy solo mode migrates to client with direct upstream and local current management."], + ["磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号;切到其他号时 disk 不动、proxy 出口照切(手机 bridge 不掉线)。仅 ChatGPT 订阅号可绑定。","Disk ~/.codex/auth.json stays bound to this account for Codex phone remote access. Switching proxy accounts leaves disk identity unchanged and keeps the phone bridge connected. Only ChatGPT subscriptions can be anchors."], + ["磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号;","Disk ~/.codex/auth.json stays bound to this account for Codex phone remote access;"], + ["切到其他号时 disk 不动、proxy 出口照切(手机 bridge 不掉线)。","proxy switches leave disk identity unchanged and keep the phone bridge connected."], + ["仅 ChatGPT 订阅号可绑定。","Only ChatGPT subscription accounts can be anchors."], + ["原理","How it works"], + ["每次切号前后强制抓 quota 写","Capture quota before and after each switch in"], + ["同一窗口内任意两次快照的","Within a window, the change between any two snapshots"], + ["任何两次快照的","Between any two snapshots"], + ["配合期间代理 tokens","combined with proxy token usage"], + ["推出该 Plan 总容量","estimates total plan capacity"], + ["推出 Plan 总容量","estimates plan capacity"], + ["中位","Median"], + ["是去掉异常值后最稳的估计","is the most stable estimate after removing outliers"], + ["是去掉异常值后的稳定估计","is a stable estimate after removing outliers"], + ["Δpct<3% 的样本被丢弃","Samples with percentage change below 3% are discarded"], + ["样本被丢弃","Samples are discarded"], + ["used_pct 是整数","used_pct is an integer"], + ["量化误差会失真","Rounding errors distort the estimate"], + ["样本会随每次切号自动累积","Samples accumulate automatically with each switch"], + ["样本数低于","With fewer than"], + ["时估计仍有偏差,多用几小时即可","samples, estimates remain uncertain. More usage improves them"], + ["每号 Token 历史(精确累加 + 估算上限)","Per-account token history (exact totals + estimated capacity)"], + ["邮箱 / Plan","Email / Plan"], + ["当前 5h","Current 5h"], + ["上次 5h","Previous 5h"], + ["当前 周","Current week"], + ["上次 周","Previous week"], + ["每格显示","Each cell shows"], + ["实测累加 / 估算上限","Measured total / Estimated capacity"], + ["实测累加","Measured total"], + ["在该窗口内的精确求和","Exact sum within this window"], + ["不平均,不打折,无论中间切号几次","No averaging or discounts, regardless of account switches"], + ["估算上限","Estimated capacity"], + ["用快照里 used_pct 最大的那个算","Use the snapshot with the highest used_pct"], + ["用快照里 used_pct 最大那个算","Use the snapshot with the highest used_pct"], + ["量化误差最小","Minimum rounding error"], + ["标记的 used_pct 偏小,估算误差大","Low used_pct indicates higher estimation error"], + ["窗口内触发过限额切号","A quota-triggered switch occurred in this window"], + ["此时实测累加","The measured total then approximates"], + ["实际窗口配额","Actual window quota"], + ["点开账号看历史周期,点开周期看 session 明细","Expand an account for historical cycles and a cycle for session details"], + ["最近","Recent"], + ["Token 趋势","Token Trend"], + ["费用趋势","Cost Trend"], + ["切号日志","Switch log"], + ["切换路径","Switch Path"], + ["使用时长","Duration"], + ["暂无切号记录","No switch records"], + ["Панель Prompt Cache","Prompt Cache panel"], + ["隐藏旧数据","Hide legacy data"], + ["早期版本没记 account_id 的历史条目","Historical entries from versions without account_id"], + ["近 24 小时","Last 24 hours"], + ["近 30 天","Last 30 days"], + ["节省","Savings"], + ["总花费","Total cost"], + ["次请求","requests"], + ["按模型 token 分布","Token distribution by model"], + ["Кэшированный вход","Cached input"], + ["Некэшированный вход","Uncached input"], + ["Выход","Output"], + ["本字段是这次新增的,旧条目里没有,不能事后追溯","This field was added recently and cannot be reconstructed for old entries"], + ["累计 cached tokens","Total cached tokens"], + ["健康","Healthy"], + ["失效或封号","Invalid or banned"], + ["创建于","Created"], + ["命中","Hit"], + ["从未","Never"], + ["刚刚","Just now"], + ["分钟前","minutes ago"], + ["小时前","hours ago"], + ["昨天","Yesterday"], + ["天前","days ago"], + ["中转服务","Relay provider"], + ["选择预设","Choose preset"], + ["账号名称 *","Account Name *"], + ["例如:unity2-工作","For example: unity2-work"], + ["例如:工作 · GLM Coding","For example: Work · GLM Coding"], + ["例如:工作账号、个人账号","e.g. Work Account, Personal Account"], + ["可选的备注信息...","Optional notes..."], + ["主页","Homepage"], + ["名称","Name"], + ["备注(可选)","Notes (optional)"], + ["默认自动探测","Automatic detection by default"], + ["自动探测(推荐 · new-api / sub2api 都能识别)","Auto-detect (recommended; supports new-api / sub2api)"], + ["responses(默认 / Unity2、ChatGPT、OpenAI key)","responses (default / Unity2, ChatGPT, OpenAI key)"], + ["chat_completions(GLM/MiMo Coding Plan / 通用 OpenAI Chat)","chat_completions (GLM/MiMo Coding Plan / OpenAI Chat)"], + ["留空 = 透传不替换","Empty = pass through without replacement"], + ["每行 客户端model=中转站model","One mapping per line: client_model=relay_model"], + ["添加中…","Adding…"], + ["Base URL 已自动填好","Base URL filled automatically"], + ["通用中转","General relays"], + ["三方模型","Third-party models"], + ["自定义","Custom"], + ["厂商自家订阅","Provider subscriptions"], + ["厂商按量付费 API","Provider pay-as-you-go APIs"], + ["中转 / Plan / 三方 出问题时切回订阅号","Switch back to subscriptions when relay / plan / third-party accounts fail"], + ["自动选号可挑中 中转 / Plan / 三方","Include relays / plans / third-party accounts in automatic selection"], + ["智谱 GLM","Zhipu GLM"], + ["通用 Responses 中转(new-api / CLIProxyAPI / sub2api)","General Responses relay (new-api / CLIProxyAPI / sub2api)"], + ["自定义中转站","Custom relay"], + ["阿里 DashScope (通义千问)","Alibaba DashScope (Qwen)"], + ["火山方舟 (ByteDance Doubao)","Volcengine Ark (ByteDance Doubao)"], + ["腾讯混元","Tencent Hunyuan"], + ["百度千帆 (ERNIE)","Baidu Qianfan (ERNIE)"], + ["优云智算 UCloud Modelverse","UCloud Modelverse"], + ["阶跃星辰 Stepfun","StepFun"], + ["神马中转 API (Whatai)","Shenma API (Whatai)"], + ["魔搭 ModelScope","ModelScope"], + ["Ollama 本地推理","Local Ollama inference"], + ["按量付费","Pay-as-you-go"], + ["中国订阅","China subscription"], + ["国际订阅","International subscription"], + ["余额自动探测","Automatic balance detection"], + ["OpenAI 兼容","OpenAI compatible"], + ["OpenAI Chat 兼容","OpenAI Chat compatible"], + ["配额用控制台 Cookie","Quota uses console Cookie"], + ["MiMo 配额 Cookie","MiMo quota Cookie"], + ["从 platform.xiaomimimo.com Network 复制 Cookie header","Copy Cookie header from platform.xiaomimimo.com Network"], + ["Prompt Cache 面板","Prompt Cache panel"], + ["还没有任何 session 命中过 cache","No sessions have hit the cache yet"], + ["活跃 session 绑定","Active session bindings"], + ["活跃 Session 绑定(evidence-based stickiness)","Active session bindings (evidence-based affinity)"], + ["命中率(按小时)","Hourly cache hit rate"], + ["按模型 token 分布(cached vs uncached vs output)","Tokens by model (cached / uncached / output)"], + ["vs 全价 input","vs full-price input"], + ["请求数","Requests"], + ["命中率","Hit rate"], + ["全部项目","All projects"], + ["近 90 天","Last 90 days"], + ["Token 总量","Total Tokens"], + ["总费用","Total Cost"], + ["切号次数","Switch Count"], + ["使用账号数","Accounts Used"], + ["Plan 配额上限估算(Δpct 反推,不依赖账号打满)","Estimated plan capacity (percentage-change based; no need to exhaust quota)"], + ["5h 样本","5h samples"], + ["5h 中位","5h median"], + ["5h 平均","5h average"], + ["5h min–max","5h min–max"], + ["5h min-max","5h min–max"], + ["5H MIN-MAX","5H MIN-MAX"], + ["5h MIN-MAX","5h MIN-MAX"], + ["MIN-MAX 周","Weekly min–max"], + ["周 样本","Weekly samples"], + ["周 中位","Weekly median"], + ["周 平均","Weekly average"], + ["周 min–max","Weekly min–max"], + ["样本会随每次切号自动累积;样本数低于 ~5 时估计仍有偏差,多用几小时即可。","Samples accumulate with each account switch. Estimates with fewer than about five samples may be inaccurate; more usage improves them."], + ["5h 周期","5h cycles"], + ["周周期","Weekly cycles"], + ["每格显示「实测累加 / 估算上限」。","Each cell shows measured total / estimated capacity."], + ["窗口","Window"], + ["轮数","Turns"], + ["session 数","Sessions"], + ["状态","Status"], + ["进行中","In progress"], + ["正常","Normal"], + ["暂无数据","No data yet"], + ["后台任务日志","Background task log"], + ["时间","Time"], + ["原因","Reason"], + ["目标账号","Target Account"], + ["任务类型","Task Type"], + ["刷新后额度","Quota After Refresh"], + ["成功","Success"], + ["手动","Manual"], + ["自动刷新","Auto Refresh"], + ["后台保活","Background Keepalive"], + ["限额","Limit"], + ["配额充足","Quota OK"], + ["配额偏低","Low Quota"], + ["加载统计数据失败:","Failed to load stats:"], + ["Remote Mode(局域网同步)","Remote mode (LAN synchronization)"], + ["工作模式","Operating mode"], + ["仅本机","Local Only"], + ["client(本机)","client (local app)"], + ["server(Server 侧)","server (Server API)"], + ["solo(本机接管保活)","solo (local keepalive management)"], + ["Server API 地址(主)","Primary Server API URL"], + ["Server API 地址(回退)","Fallback Server API URL"], + ["优先尝试,建议填局域网 IP,如 http:","Tried first. Use a LAN IP, such as http:"], + ["主不通时自动切换,建议填 ZeroTier IP,如 http:","Used if primary is unreachable. Use a ZeroTier IP, such as http:"], + ["共享密钥","Shared secret"], + ["必须与 Server 端一致","Must match the Server secret"], + ["已生成新密钥,记得保存设置","New secret generated; remember to save settings"], + ["监听地址","Listen address"], + ["监听端口","Listen port"], + ["Server 侧 HTTP API 端口(默认 18081)","Server HTTP API port (default 18081)"], + ["0.0.0.0 监听所有网卡;建议仅暴露给 ZeroTier 网段","0.0.0.0 listens on all interfaces; expose only to the ZeroTier subnet if possible"], + ["客户端访问需携带 X-Auth-Token;留空则拒绝所有请求","Clients must send X-Auth-Token; an empty secret rejects all requests"], + ["测试连通性 / 推送本机账号 / 从 Server 合并","Test connectivity / push local accounts / merge from Server"], + ["推送全部","Push all"], + ["拉取合并","Pull and merge"], + ["同步所有 token","Synchronize all tokens"], + ["自动同号","Follow Server account automatically"], + ["本机管 current 指针","Manage current locally"], + ["当前账号只做权威回流;非活跃账号按独占策略保活刷新(保存后生效)","Current account only authoritative sync; inactive accounts keepalive refresh by exclusive strategy (effective after save)"], + ["手工拉取 Server 当前账号并热切过去(自动同号关了也可用)","Pull Server current account and hot-switch to it manually, even when automatic following is disabled"], + ["立即同号","Follow Server now"], + ["后台保活与同步","Background Keepalive & Sync"], + ["非活跃保活阈值(天)","Inactive Keepalive Threshold (days)"], + ["当账号 last_refresh 超过该阈值时,调度器才会尝试保活刷新","Scheduler only attempts keepalive refresh when account last_refresh exceeds this threshold"], + ["调度间隔(分钟)","Schedule Interval (minutes)"], + ["自动重载 IDE","Auto Reload IDE"], + ["切换账号后自动重载 IDE 以应用新的 Token","Auto reload IDE after switching account to apply new token"], + ["仅重载选中的 IDE","Only reload selected IDE"], + ["使用杀进程重启","Use Kill Process Restart"], + ["使用 pkill 方式重启(Windsurf 推荐,无需权限)","Use pkill restart (recommended for Windsurf, no permissions needed)"], + ["修复 Codex App 闪退","Fix Codex App Crash"], + ["移除 macOS 安全隔离属性 (需要管理员权限)","Remove macOS quarantine attribute (requires admin privileges)"], + ["这将尝试移除 Codex App 的安全隔离属性。","This will attempt to remove Codex App quarantine attributes."], + ["系统可能会弹窗要求输入密码以获得权限。是否继续?","The system may ask for an administrator password. Continue?"], + ["❌ 修复失败:","❌ Fix failed:"], + ["安装","Install"], + ["仓库已存在","Repo already exists"], + ["读取失败:","Read failed:"], + ["5H 限额","5-Hour Quota"], + ["24H 限额","24H Limit"], + ["周限额","Weekly Quota"], + ["周配额","Weekly Quota"], + ["5h 配额","5h Quota"], + ["加载用量...","Loading usage..."], + ["即将重置","Resetting soon"], + ["后重置","until reset"], + ["小时","h"], + ["分钟","m"], + ["秒","s"], + ["未知","Unknown"], + ["中转账号请用「中转余额刷新」","Use relay balance refresh for relay accounts"], + ["未设置当前账号","No current account set"], + ["网络请求失败,检查代理/网络","Network request failed; check proxy and connectivity"], + ["刷新失败:网络或服务暂时异常,未判定账号失效","Refresh failed due to a temporary network or service problem; account was not marked invalid"], + ["请求超时(OpenAI 端慢/被节流)","Request timed out (OpenAI slow or throttled)"], + ["refresh_token 刷新失败","refresh_token refresh failed"], + ["网络请求失败","Network request failed"], + ["已设为手机锚","Set as phone anchor"], + ["已解除手机锚绑定","Phone anchor removed"], + ["选择手机锚账号","Choose phone anchor account"], + ["手机锚:磁盘 ~/.codex/auth.json 永远跟随此号,Codex.app 手机远程连接绑定此号;切到其他号时 disk 不动、proxy 出口照切","Phone anchor: ~/.codex/auth.json remains bound to this account for Codex phone remote access, while proxy accounts can switch independently"], + ["仅 ChatGPT 订阅号可绑定(Codex.app 远程连接需要 chatgpt_account_id 鉴权)","Only ChatGPT subscriptions can be anchors; remote access requires chatgpt_account_id"], + ["周期保鲜","Window priming"], + ["保鲜已关","Priming disabled"], + ["最近尝试","Last attempt"], + ["最近成功","Last success"], + ["最近结果","Last result"], + ["按 primary_window 实际时长自动识别","Detected from actual primary_window duration"], + ["接口返回 · 5 小时窗口","API window · 5 hours"], + ["接口返回 · 7 天窗口","API window · 7 days"], + ["正在拉取重置次数明细…","Fetching reset credit details…"], + ["拉取明细失败","Failed to fetch details"], + ["Input","Input"], + ["Cached","Cached"], + ["Output","Output"], + ["Session Key","Session Key"], + ["Cached input","Cached input"], + ["Uncached input","Uncached input"], + ["cached / input","cached / input"], + ["owner","owner"], + ["repo","repo"], + ["branch","branch"], + ["evidence-based","evidence-based"], + ["evidence-based stickiness","evidence-based stickiness"], + ["Email","Email"], + ["Free","Free"], + ["Plus","Plus"], + ["Team","Team"], + ["后台服务","Background Service"], + ["HTTP 也走本机直连上游","Connect HTTP directly to upstream"], + ["IDE 重载","IDE Reload"], + ["主力 IDE","Primary IDE"], + ["绑定地址","Bind address"], + ["修改端口/绑定/密钥后点此应用(保存设置后)","Apply changed port / address / secret after saving settings"], + ["同步操作","Synchronization"], + ["Codex.app 手机锚绑定","Codex.app phone anchor"], + ["故障修复","Troubleshooting"], + ["代理服务","Proxy Service"], + ["代理端口","Proxy Port"], + ["开启后监听 `0.0.0.0`,同一局域网内的 Windows 可直接连接这台机器的代理","Listen on 0.0.0.0, Windows machines on same LAN can connect directly to this proxy"], + ["切号模式","Account switching mode"], + ["auto(代理开=热切)","auto (hot switch while proxy runs)"], + ["cold(强制冷切)","cold (always cold switch)"], + ["Windows 机器可把 `OPENAI_BASE_URL` 指向下面这个地址","Windows machines can point OPENAI_BASE_URL to the address below"], + ["5H","5H"], + ["5h","5h"], + ["7D","7D"], + ["7d","7d"], + ["Spark 5H","Spark 5H"], + ["登录账号 (OpenAI / OTP / 导入)","Sign in (OpenAI / OTP / import)"], + ["用该账号开一个真 codex 终端(隔离 + 直连),可发一句\"你好\"触发 referral 兑现","Open a real isolated Codex terminal with direct upstream. A short Hello can trigger referral redemption"], + ["推送到 Server","Push to Server"], + ["确认删除账号","Confirm Delete Account"], + ["彻底删除","Delete Permanently"], + ["不打开默认浏览器,把授权链接复制到剪贴板,由你粘贴到目标浏览器","Copy the authorization link without opening the default browser, then paste it into your chosen browser"], + ["刷新额度","Refresh quota"], + ["授权成功","Authorization successful"], + ["OAuth 授权成功","OAuth authorization successful"], + ["无法获取用户目录","Unable to resolve user directory"], + ["设置文件权限失败","Failed to set file permissions"], + ["设置目录权限失败","Failed to set directory permissions"], + ["写入文件失败","Failed to write file"], + ["创建目录失败","Failed to create directory"], + ["创建临时文件失败","Failed to create temporary file"], + ["写入临时文件失败","Failed to write temporary file"], + ["重命名临时文件失败","Failed to rename temporary file"], + ["重命名文件失败 (Atomic Write)","Failed to rename file (atomic write)"], + ["读取 auth.json 失败","Failed to read auth.json"], + ["解析 auth.json 失败","Failed to parse auth.json"], + ["未找到 Codex auth.json,请先登录 Codex","Codex auth.json not found. Please sign in to Codex first"], + ["账号不存在","Account does not exist"], + ["当前账号不存在","Current account does not exist"], + ["没有激活的账号","No active account"], + ["当前账号缺少 access_token","Current account missing access_token"], + ["session 绑定账号不存在","Session-bound account not found"], + ["session 绑定账号缺 access_token","Session-bound account lacks access_token"], + ["账号到期日格式必须是 YYYY-MM-DD","Account expiry must use YYYY-MM-DD format"], + ["不是中转站账号","Not a relay account"], + ["无效的 Token 格式","Invalid token format"], + ["Base64 解码失败","Base64 decoding failed"], + ["UTF-8 转换失败","UTF-8 conversion failed"], + ["JSON 解析失败","JSON parsing failed"], + ["文件不是 UTF-8 文本","File is not UTF-8 text"], + ["无法识别的 JSON 结构","Unrecognized JSON structure"], + ["无法识别的文件格式","Unrecognized file format"], + ["zip 解析失败","Zip parsing failed"], + ["缺 email","Missing email"], + ["缺 access_token","Missing access_token"], + ["缺 refresh_token","Missing refresh_token"], + ["无 accounts 数组","No accounts array"], + ["缺 credentials","Missing credentials"], + ["native: 缺 accounts 对象","native: missing accounts object"], + ["session JSON 不能为空","Session JSON cannot be empty"], + ["未找到包含 accessToken 的 session 对象","No session object containing accessToken found"], + ["缺少 accessToken","Missing accessToken"], + ["执行失败","Execution failed"], + ["未找到运行中的 codex 进程","No running codex processes found"], + ["未配置 Server 地址","Server URL not configured"], + ["未配置共享密钥","Shared secret not configured"], + ["共享密钥为空,请先生成","Shared secret is empty; generate one first"], + ["磁盘账号不在管理列表中,请先导入","Disk account not in management list, please import first"], + ["手机锚只能设在 ChatGPT 订阅号上","Only ChatGPT subscription accounts can be phone anchors"], + ["拒绝同步:身份不匹配","Synchronization rejected: identity mismatch"], + ["拒绝同步:账号名与 token 邮箱不一致","Synchronization rejected: account name does not match token email"], + ["刷新令牌被拒绝","Refresh token rejected"], + ["刷新令牌失败","Refresh token failed"], + ["发现失败","Discovery failed"], + ["安装失败","Installation failed"], + ["卸载失败","Uninstall failed"], + ["操作失败","Operation failed"], + ["同步失败","Synchronization failed"], + ["✓ 含 refresh_token","✓ Includes refresh_token"], + ["⚠ 无 refresh_token,到期后需重新导入","⚠ No refresh_token; re-import after expiry"], + ["每行一个,自动识别 provider","One per line; provider detected automatically"], + ["运行中","Running"], + ["等待中","Waiting"], + ["Base URL 必须以 http:// 或 https:// 开头","Base URL must start with http:// or https://"], + ["API Key 看起来太短","API key looks too short"], + ["账号名不能为空","Account name cannot be empty"], + ["已封号","Banned"], + ["Token 失效","Token invalid"], + ["日期异常","Invalid date"], + ["已过期","Expired"], + ["天到期","days until expiry"], + ["启动中…","Starting…"], + ["启动 codex","Start Codex"], + ["周期保鲜最近错误","Latest window-priming error"], + ["周期保鲜会在额度窗口到点后自动发一次最小 Codex 请求","Window priming sends one minimal Codex request when a quota window resets"], + ["主动重置","Manual reset"], + ["保活:","Keepalive:"], + ["刷新:","Refresh:"], + ["到期:","Expiry:"], + ["已选","Selected"], + ["按账号","By account"], + ["绑定账号","Bound account"], + ["命中次数","Hits"], + ["年龄","Age"], + ["花费","Cost"], + ["搜索 label / session_id / 账号…","Search label / session_id / account…"], + ["搜索 cwd / 首条消息…","Search cwd / first message…"], + ["搜索账号…","Search accounts…"], + ["搜索邮箱 / 订阅类型 (如 team / pro / free)…","Search email / plan (team / pro / free)…"], + ["模型分布","Model Distribution"], + ["切号原因","Switch Reasons"], + ["费用","Cost"], + ["上次","Previous"], + ["当前","Current"], + ["周期","Cycle"], + ["订阅","Subscription"], + ["代理","Proxy"], + ["账号","Account"], + ["额度","Quota"], + ["配额","Quota"], + ["中转","Relay"], + ["保活","Keepalive"], + ["绑定","Bind"], + ["解除","Unbind"], + ["重试","Retry"], + ["错误","Error"], + ["开启","Enable"], + ["启用","Enable"], + ["禁用","Disable"], + ["已启用","Enabled"], + ["已禁用","Disabled"], + ["已停止","Stopped"], + ["已启动","Started"], + ["未设置","Not set"], + ["未绑定","Not bound"], + ["自动","Automatic"], + ["本地","Local"], + ["远程","Remote"], + ["全量","Full"], + ["备注","Notes"], + ["说明","Description"], + ["来源","Source"], + ["到期","Expiry"], + ["邮箱","Email"], + ["日期","Date"], + ["当前号","Current account"], + ["封","Banned"], + ["条","entries"], + ["个","selected"], + [",",", "], + ["。",". "], + [":",": "], + ["("," ("], + [")",")"], + ["、",", "], + [";","; "], + ["「","“"], + ["」","”"], + ["【","["], + ["】","]"], +]; + +const translateMessage = createTranslator(englishReplacements); +export function translateEn(value: string): string { + const countdown = value.trim().match(/^((?:\d+\s*(?:天|小时|分钟|秒|时|分)\s*)+)后重置$/); + if (countdown) { + const units: Record = { 天: 'd', 小时: 'h', 分钟: 'm', 秒: 's', 时: 'h', 分: 'm' }; + const duration = [...countdown[1].matchAll(/(\d+)\s*(天|小时|分钟|秒|时|分)/g)] + .map((part) => part[1] + units[part[2]]).join(' '); + return duration + ' until reset'; + } + return translateMessage(value); +} +export const englishLocale: UiLocale = { code: 'en', title: 'Codex Switcher', translate: translateEn }; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index ca67504..fb03fcf 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,32 +1,107 @@ import { russianLocale } from './ru'; -import { installUiLocale } from './runtime'; +import { englishLocale } from './en'; +import { installUiLocale, type UiLocale } from './runtime'; -const locales = new Map([ - ['ru', russianLocale], -]); +export interface AppLocaleDefinition { + code: string; + languagePrefixes: readonly string[]; + nativeName: string; + flag: string; + translation?: UiLocale; + contributors?: readonly LocaleContributor[]; +} + +export interface LocaleContributor { + name: string; + url?: string; +} + +export const SOURCE_LOCALE_CODE = 'zh-CN'; +export const AUTO_LOCALE = 'auto'; -export type AppLocaleCode = 'ru' | 'zh-CN'; +export const appLocales: readonly AppLocaleDefinition[] = [ + { + code: SOURCE_LOCALE_CODE, + languagePrefixes: ['zh'], + nativeName: '中文', + flag: '🇨🇳', + }, + { + code: 'en', + languagePrefixes: ['en'], + nativeName: 'English', + flag: '🇬🇧', + translation: englishLocale, + contributors: [{ name: 'InventiveSpark', url: 'https://github.com/InventiveSpark' }], + }, + { + code: 'ru', + languagePrefixes: ['ru'], + nativeName: 'Русский', + flag: '🇷🇺', + translation: russianLocale, + contributors: [ + { name: 'Ivan4537', url: 'https://github.com/Ivan4537' }, + ], + }, +]; + +export type AppLocaleCode = string; +export type LocalePreference = typeof AUTO_LOCALE | AppLocaleCode; + +const localePreferenceKey = 'codex-switcher.locale'; +let activeLocaleCode = SOURCE_LOCALE_CODE; function requestedLocale(): string { - return (navigator.language || 'zh-CN').toLowerCase(); + return (navigator.language || SOURCE_LOCALE_CODE).toLowerCase(); +} + +function findLocale(code: string): AppLocaleDefinition | undefined { + return appLocales.find(locale => locale.code.toLowerCase() === code.toLowerCase()); } -export function resolveAppLocale(language: string): AppLocaleCode { +export function resolveAppLocale( + language: string, + preference: LocalePreference = AUTO_LOCALE, +): AppLocaleCode { + if (preference !== AUTO_LOCALE) { + const preferred = findLocale(preference); + if (preferred) return preferred.code; + } const normalized = language.toLowerCase(); - return [...locales.keys()].some(code => normalized.startsWith(code)) ? 'ru' : 'zh-CN'; + return appLocales.find(locale => ( + locale.languagePrefixes.some(prefix => normalized.startsWith(prefix.toLowerCase())) + ))?.code ?? SOURCE_LOCALE_CODE; +} + +export function getLocalePreference(): LocalePreference { + try { + const stored = localStorage.getItem(localePreferenceKey); + return stored && (stored === AUTO_LOCALE || findLocale(stored)) ? stored : AUTO_LOCALE; + } catch { + return AUTO_LOCALE; + } +} + +export function setLocalePreference(preference: LocalePreference) { + if (preference !== AUTO_LOCALE && !findLocale(preference)) return; + localStorage.setItem(localePreferenceKey, preference); + window.location.reload(); +} + +export function getActiveLocale(): AppLocaleDefinition { + return findLocale(activeLocaleCode) ?? appLocales[0]; } // Chinese remains the source language and the fallback for unsupported locales. export function installAppLocale(systemLocale?: string): AppLocaleCode { - const language = systemLocale ?? requestedLocale(); - const localeCode = resolveAppLocale(language); - const locale = localeCode === 'ru' ? russianLocale : undefined; - if (locale) { - installUiLocale(locale); - return localeCode; + activeLocaleCode = resolveAppLocale(systemLocale ?? requestedLocale(), getLocalePreference()); + const locale = getActiveLocale(); + if (locale.translation) { + installUiLocale(locale.translation); } else { - document.documentElement.lang = 'zh-CN'; + document.documentElement.lang = locale.code; document.title = 'Codex Switcher'; - return 'zh-CN'; } + return locale.code; } diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 5db97d5..73d2d51 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -3,6 +3,8 @@ import type { UiLocale } from './runtime'; type Replacement = readonly [source: string, target: string]; const replacements: Replacement[] = [ + ['切换语言', 'Сменить язык'], + ['跟随系统', 'Системный язык'], ['Turn-State 被动观测', 'Пассивное наблюдение Turn-State'], ['响应头观测', 'Наблюдения заголовков ответа'], ['292 正常', '292: нормальная форма'], @@ -705,6 +707,8 @@ const replacements: Replacement[] = [ ['暗黑护眼', 'Тёмная тема'], ['关于', 'О программе'], ['多账号智能切换 + 本地代理 + 用量统计', 'Умное переключение аккаунтов, локальный прокси и статистика использования'], + ['翻译', 'Переводы'], + ['感谢帮助应用支持更多语言的贡献者', 'Авторы и участники локализации'], ['你好,', 'Здравствуйте,'], ['用户', 'пользователь'], ['手机锚生效:', 'Мобильная привязка активна:'], diff --git a/src/i18n/translator.ts b/src/i18n/translator.ts new file mode 100644 index 0000000..9d2ecfb --- /dev/null +++ b/src/i18n/translator.ts @@ -0,0 +1,38 @@ +/** Exact and templated UI messages take priority over fragment translation. */ +export function createTranslator(replacements: readonly (readonly [string, string])[]) { + const normalize = (value: string) => value.replace(/\s+/g, ' ').trim(); + const exact = new Map(replacements); + const normalized = new Map(replacements.map(([source, target]) => [normalize(source), target])); + const placeholder = /\{[^{}]*\}/g; + const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const templates = replacements.filter(([source]) => /\{[^{}]*\}/.test(source)) + .sort((a, b) => b[0].length - a[0].length) + .map(([source, target]) => ({ + pattern: new RegExp(`^${normalize(source).split(placeholder).map(escape).join('(.*?)')}$`, 'u'), + target, + })); + const fragments = replacements.filter(([source]) => /[\u4e00-\u9fff():「」【】、,。;]/.test(source)) + .sort((a, b) => b[0].length - a[0].length); + + return (value: string): string => { + if (!value) return value; + const leading = value.match(/^\s*/)?.[0] ?? ''; + const trailing = value.match(/\s*$/)?.[0] ?? ''; + const core = value.trim(); + const whole = exact.get(core) ?? normalized.get(normalize(core)); + if (whole !== undefined) return leading + whole + trailing; + for (const { pattern, target } of templates) { + const match = pattern.exec(normalize(core)); + if (match) { + let capture = 1; + return leading + target.replace(placeholder, () => match[capture++] ?? '') + trailing; + } + } + if (!/[\u4e00-\u9fff]/.test(value)) return value; + let translated = value; + for (const [source, target] of fragments) { + translated = translated.split(source).join(target); + } + return translated; + }; +}