From ad1387d07663627978717ef66bc7ba45c67030a4 Mon Sep 17 00:00:00 2001 From: Chebotov Nickolay Date: Fri, 6 Feb 2026 11:26:32 +0300 Subject: [PATCH 01/24] feat(i18n): add Russian locale and enable 'ru' language; translate core keys to Russian --- src/i18n/index.ts | 4 +- src/i18n/locales/ru.json | 1113 ++++++++++++++++++++++++++++++++++++++ src/types/common.ts | 2 +- src/utils/language.ts | 9 +- 4 files changed, 1123 insertions(+), 5 deletions(-) create mode 100644 src/i18n/locales/ru.json diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 600d753..930e323 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -6,12 +6,14 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import zhCN from './locales/zh-CN.json'; import en from './locales/en.json'; +import ru from './locales/ru.json'; import { getInitialLanguage } from '@/utils/language'; i18n.use(initReactI18next).init({ resources: { 'zh-CN': { translation: zhCN }, - en: { translation: en } + en: { translation: en }, + ru: { translation: ru } }, lng: getInitialLanguage(), fallbackLng: 'zh-CN', diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json new file mode 100644 index 0000000..e627273 --- /dev/null +++ b/src/i18n/locales/ru.json @@ -0,0 +1,1113 @@ +{ + "common": { + "login": "Войти", + "logout": "Выйти", + "back": "Назад", + "cancel": "Отмена", + "confirm": "Подтвердить", + "save": "Сохранить", + "delete": "Удалить", + "edit": "Редактировать", + "add": "Добавить", + "update": "Обновить", + "refresh": "Обновить", + "close": "Закрыть", + "success": "Успешно", + "error": "Ошибка", + "info": "Информация", + "warning": "Внимание", + "loading": "Загрузка...", + "connecting": "Подключение...", + "connected": "Подключено", + "disconnected": "Отключено", + "connecting_status": "Подключение", + "connected_status": "Подключено", + "disconnected_status": "Отключено", + "yes": "Да", + "no": "Нет", + "not_set": "Не задано", + "optional": "Необязательно", + "required": "Обязательно", + "api_key": "Ключ", + "base_url": "Адрес", + "prefix": "Префикс", + "proxy_url": "Прокси", + "alias": "Псевдоним", + "failure": "Сбой", + "unknown_error": "Неизвестная ошибка", + "quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений", + "quota_check_credential": "Пожалуйста, проверьте статус учётных данных", + "copy": "Копировать", + "custom_headers_label": "Пользовательские заголовки", + "custom_headers_hint": "Необязательно — HTTP-заголовки для отправки с запросом. Оставьте пустым для удаления.", + "custom_headers_add": "Добавить заголовок", + "custom_headers_key_placeholder": "Имя заголовка, например X-Custom-Header", + "custom_headers_value_placeholder": "Значение заголовка", + "model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022", + "model_alias_placeholder": "Псевдоним модели (необязательно)" + }, + "title": { + "main": "CLI Proxy API Management Center", + "login": "CLI Proxy API Management Center", + "abbr": "CPAMC" + }, + "auto_login": { + "title": "Автовход...", + "message": "Пытаемся подключиться к серверу, используя сохранённые данные" + }, + "login": { + "subtitle": "Введите данные подключения, чтобы получить доступ к панели управления", + "connection_title": "Адрес подключения", + "connection_current": "Текущий URL", + "connection_auto_hint": "Система автоматически использует текущий URL для подключения", + "custom_connection_label": "Пользовательский URL подключения:", + "custom_connection_placeholder": "Напр.: https://example.com:8317", + "custom_connection_hint": "По умолчанию используется текущий URL. При необходимости замените его.", + "use_current_address": "Использовать текущий URL", + "remember_password_label": "Запомнить пароль", + "management_key_label": "Ключ управления:", + "management_key_placeholder": "Введите ключ управления", + "connect_button": "Подключиться", + "submit_button": "Войти", + "submitting": "Подключение...", + "error_title": "Ошибка входа", + "error_required": "Пожалуйста, заполните все данные подключения", + "error_invalid": "Подключение не удалось, проверьте адрес и ключ", + "error_network": "Сетевая ошибка, проверьте подключение или адрес сервера", + "error_timeout": "Время ожидания истекло, сервер не отвечает", + "error_unauthorized": "Аутентификация не удалась, неверный ключ управления", + "error_forbidden": "Доступ запрещён, недостаточно прав", + "error_not_found": "Неверный адрес сервера или интерфейс управления не включён", + "error_server": "Внутренняя ошибка сервера, попробуйте позже", + "error_cors": "Блокировка CORS, проверьте конфигурацию сервера", + "error_ssl": "Ошибка проверки SSL/TLS сертификата" + }, + "header": { + "check_connection": "Проверить подключение", + "refresh_all": "Обновить всё", + "logout": "Выйти" + }, + "connection": { + "title": "Информация о подключении", + "server_address": "Адрес сервера:", + "management_key": "Ключ управления:", + "status": "Статус подключения:" + }, + "nav": { + "dashboard": "Панель", + "basic_settings": "Основные настройки", + "api_keys": "API ключи", + "ai_providers": "Поставщики AI", + "auth_files": "Файлы аутентификации", + "oauth": "OAuth вход", + "quota_management": "Управление квотами", + "usage_stats": "Статистика использования", + "config_management": "Панель конфигурации", + "logs": "Просмотр логов", + "system_info": "Информация системы" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Welcome to CLI Proxy API Management Center", + "openai_providers": "OpenAI Providers", + "quick_actions": "Quick Actions", + "current_config": "Current Configuration", + "management_keys": "Management Keys", + "provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}", + "oauth_credentials": "OAuth Credentials", + "usage_overview": "Usage Overview", + "total_requests": "Total Requests", + "total_tokens": "Total Tokens", + "rpm_30min": "RPM (30min)", + "tpm_30min": "TPM (30min)", + "models_used": "Models Used", + "no_usage_data": "No usage data available", + "view_detailed_usage": "View Detailed Stats", + "edit_settings": "Edit Settings", + "available_models": "Available Models", + "available_models_desc": "Total models from all providers" + }, + "basic_settings": { + "title": "Basic Settings", + "debug_title": "Debug Mode", + "debug_enable": "Enable Debug Mode", + "proxy_title": "Proxy Settings", + "proxy_url_label": "Proxy URL:", + "proxy_url_placeholder": "e.g.: socks5://user:pass@127.0.0.1:1080/", + "proxy_update": "Update", + "proxy_clear": "Clear", + "retry_title": "Request Retry", + "retry_count_label": "Retry Count:", + "retry_update": "Update", + "quota_title": "Quota Exceeded Behavior", + "quota_switch_project": "Auto Switch Project", + "quota_switch_preview": "Switch to Preview Model", + "usage_statistics_title": "Usage Statistics", + "usage_statistics_enable": "Enable usage statistics", + "logging_title": "Logging", + "logging_to_file_enable": "Enable logging to file", + "logs_max_total_size_title": "Log Size Limit", + "logs_max_total_size_label": "Total log size cap (MB):", + "logs_max_total_size_hint": "Set to 0 to disable the limit.", + "logs_max_total_size_update": "Update", + "request_log_title": "Request Logging", + "request_log_enable": "Enable request logging", + "request_log_warning": "Keep this off unless you need detailed troubleshooting.", + "force_model_prefix_enable": "Force model prefix", + "ws_auth_title": "WebSocket Authentication", + "ws_auth_enable": "Require auth for /ws/*", + "routing_title": "Routing Strategy", + "routing_strategy_label": "Routing strategy:", + "routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.", + "routing_strategy_update": "Update", + "routing_strategy_round_robin": "round-robin (cycle)", + "routing_strategy_fill_first": "fill-first (prioritize)" + }, + "api_keys": { + "title": "API Keys Management", + "proxy_auth_title": "Proxy Service Authentication Keys", + "add_button": "Add Key", + "empty_title": "No API Keys", + "empty_desc": "Click the button above to add the first key", + "item_title": "API Key", + "add_modal_title": "Add API Key", + "add_modal_key_label": "API Key:", + "add_modal_key_placeholder": "Please enter API key", + "edit_modal_title": "Edit API Key", + "edit_modal_key_label": "API Key:", + "delete_confirm": "Are you sure you want to delete this API key?" + }, + "ai_providers": { + "title": "AI Providers Configuration", + "gemini_title": "Gemini API Keys", + "gemini_add_button": "Add Key", + "gemini_empty_title": "No Gemini Keys", + "gemini_empty_desc": "Click the button above to add the first key", + "gemini_item_title": "Gemini Key", + "gemini_add_modal_title": "Add Gemini API Key", + "gemini_add_modal_key_label": "API Keys:", + "gemini_add_modal_key_placeholder": "Enter Gemini API key", + "gemini_add_modal_key_hint": "Add keys one by one and optionally specify a Base URL.", + "gemini_keys_add_btn": "Add Key", + "gemini_base_url_label": "Base URL (Optional):", + "gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com", + "gemini_edit_modal_title": "Edit Gemini API Key", + "gemini_edit_modal_key_label": "API Key:", + "gemini_delete_confirm": "Are you sure you want to delete this Gemini key?", + "excluded_models_label": "Excluded models (optional):", + "excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash", + "excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.", + "excluded_models_count": "Excluding {{count}} models", + "prefix_label": "Prefix (Optional):", + "prefix_placeholder": "e.g.: team-a", + "prefix_hint": "When set, call models as prefix/ to target this entry.", + "config_toggle_label": "Enabled", + "config_disabled_badge": "Disabled", + "codex_title": "Codex API Configuration", + "codex_add_button": "Add Configuration", + "codex_empty_title": "No Codex Configuration", + "codex_empty_desc": "Click the button above to add the first configuration", + "codex_item_title": "Codex Configuration", + "codex_add_modal_title": "Add Codex API Configuration", + "codex_add_modal_key_label": "API Key:", + "codex_add_modal_key_placeholder": "Please enter Codex API key", + "codex_add_modal_url_label": "Base URL (Required):", + "codex_add_modal_url_placeholder": "e.g.: https://api.example.com", + "codex_add_modal_proxy_label": "Proxy URL (Optional):", + "codex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", + "codex_edit_modal_title": "Edit Codex API Configuration", + "codex_edit_modal_key_label": "API Key:", + "codex_edit_modal_url_label": "Base URL (Required):", + "codex_edit_modal_proxy_label": "Proxy URL (Optional):", + "codex_delete_confirm": "Are you sure you want to delete this Codex configuration?", + "claude_title": "Claude API Configuration", + "claude_add_button": "Add Configuration", + "claude_empty_title": "No Claude Configuration", + "claude_empty_desc": "Click the button above to add the first configuration", + "claude_item_title": "Claude Configuration", + "claude_add_modal_title": "Add Claude API Configuration", + "claude_add_modal_key_label": "API Key:", + "claude_add_modal_key_placeholder": "Please enter Claude API key", + "claude_add_modal_url_label": "Base URL (Optional):", + "claude_add_modal_url_placeholder": "e.g.: https://api.anthropic.com", + "claude_add_modal_proxy_label": "Proxy URL (Optional):", + "claude_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", + "claude_edit_modal_title": "Edit Claude API Configuration", + "claude_edit_modal_key_label": "API Key:", + "claude_edit_modal_url_label": "Base URL (Optional):", + "claude_edit_modal_proxy_label": "Proxy URL (Optional):", + "claude_delete_confirm": "Are you sure you want to delete this Claude configuration?", + "claude_models_label": "Custom Models (Optional):", + "claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.", + "claude_models_add_btn": "Add Model", + "claude_models_count": "Models Count", + "vertex_title": "Vertex API Configuration", + "vertex_add_button": "Add Configuration", + "vertex_empty_title": "No Vertex Configuration", + "vertex_empty_desc": "Click the button above to add the first configuration", + "vertex_item_title": "Vertex Configuration", + "vertex_add_modal_title": "Add Vertex API Configuration", + "vertex_add_modal_key_label": "API Key:", + "vertex_add_modal_key_placeholder": "Please enter Vertex API key", + "vertex_add_modal_url_label": "Base URL (Required):", + "vertex_add_modal_url_placeholder": "e.g.: https://example.com/api", + "vertex_add_modal_proxy_label": "Proxy URL (Optional):", + "vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", + "vertex_edit_modal_title": "Edit Vertex API Configuration", + "vertex_edit_modal_key_label": "API Key:", + "vertex_edit_modal_url_label": "Base URL (Required):", + "vertex_edit_modal_proxy_label": "Proxy URL (Optional):", + "vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?", + "vertex_models_label": "Model aliases (alias required):", + "vertex_models_add_btn": "Add Mapping", + "vertex_models_hint": "Each alias needs both the original model and the alias.", + "vertex_models_count": "Alias count", + "ampcode_title": "Amp CLI Integration (ampcode)", + "ampcode_modal_title": "Configure Ampcode", + "ampcode_upstream_url_label": "Upstream URL", + "ampcode_upstream_url_placeholder": "e.g. https://ampcode.com", + "ampcode_upstream_url_hint": "Optional. Leave empty to use the default/auto-discovered control plane URL.", + "ampcode_upstream_api_key_label": "Upstream API Key (Amp Official)", + "ampcode_upstream_api_key_placeholder": "Enter sk-amp... (leave empty to keep current)", + "ampcode_upstream_api_key_hint": "Optional. Leaving it empty will not change the current Amp official key. Use the button below to clear it.", + "ampcode_upstream_api_key_current": "Current Amp official key: {{key}}", + "ampcode_clear_upstream_api_key": "Clear official key", + "ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?", + "ampcode_force_model_mappings_label": "Force model mappings", + "ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.", + "ampcode_model_mappings_label": "Model mappings (from → to)", + "ampcode_model_mappings_hint": "Rewrites model names in Amp requests. Leave empty to disable mappings.", + "ampcode_model_mappings_add_btn": "Add mapping", + "ampcode_model_mappings_from_placeholder": "from model (source)", + "ampcode_model_mappings_to_placeholder": "to model (target)", + "ampcode_model_mappings_count": "Mappings Count", + "ampcode_mappings_overwrite_confirm": "Existing mappings could not be loaded. Continuing may overwrite or clear them. Continue?", + "openai_title": "OpenAI Compatible Providers", + "openai_add_button": "Add Provider", + "openai_empty_title": "No OpenAI Compatible Providers", + "openai_empty_desc": "Click the button above to add the first provider", + "openai_add_modal_title": "Add OpenAI Compatible Provider", + "openai_add_modal_name_label": "Provider Name:", + "openai_add_modal_name_placeholder": "e.g.: openrouter", + "openai_add_modal_url_label": "Base URL:", + "openai_add_modal_url_placeholder": "e.g.: https://openrouter.ai/api/v1", + "openai_add_modal_keys_label": "API Keys", + "openai_edit_modal_keys_label": "API Keys", + "openai_keys_hint": "Add each key separately with an optional proxy URL to keep things organized.", + "openai_keys_add_btn": "Add Key", + "openai_key_placeholder": "sk-... key", + "openai_proxy_placeholder": "Optional proxy URL (e.g. socks5://...)", + "openai_add_modal_models_label": "Model List (name[, alias] one per line):", + "openai_models_hint": "Example: gpt-4o-mini or moonshotai/kimi-k2:free, kimi-k2", + "openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free", + "openai_model_alias_placeholder": "Model alias (optional)", + "openai_models_add_btn": "Add Model", + "openai_models_fetch_button": "Fetch via /models", + "openai_models_fetch_title": "Pick Models from /models", + "openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", + "openai_models_fetch_url_label": "Request URL", + "openai_models_fetch_refresh": "Refresh", + "openai_models_fetch_loading": "Fetching models from /models...", + "openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.", + "openai_models_fetch_error": "Failed to fetch models", + "openai_models_fetch_back": "Back to edit", + "openai_models_fetch_apply": "Add selected models", + "openai_models_search_label": "Search models", + "openai_models_search_placeholder": "Filter by name, alias, or description", + "openai_models_search_empty": "No models match your search. Try a different keyword.", + "openai_models_fetch_invalid_url": "Please enter a valid Base URL first", + "openai_models_fetch_added": "{{count}} new models added", + "openai_edit_modal_title": "Edit OpenAI Compatible Provider", + "openai_edit_modal_name_label": "Provider Name:", + "openai_edit_modal_url_label": "Base URL:", + "openai_edit_modal_models_label": "Model List (name[, alias] one per line):", + "openai_delete_confirm": "Are you sure you want to delete this OpenAI provider?", + "openai_keys_count": "Keys Count", + "openai_models_count": "Models Count", + "openai_test_title": "Connection Test", + "openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.", + "openai_test_model_placeholder": "Model to test", + "openai_test_action": "Run Test", + "openai_test_running": "Sending test request...", + "openai_test_timeout": "Test request timed out after {{seconds}} seconds.", + "openai_test_success": "Test succeeded. The model responded.", + "openai_test_failed": "Test failed", + "openai_test_select_placeholder": "Choose from current models", + "openai_test_select_empty": "No models configured. Add models first" + }, + "auth_files": { + "title": "Auth Files Management", + "title_section": "Auth Files", + "description": "Manage all CLI Proxy JSON auth files here (e.g. Qwen, Gemini, Vertex). Uploading a credential immediately enables the corresponding AI integration.", + "upload_button": "Upload File", + "delete_all_button": "Delete All", + "empty_title": "No Auth Files", + "empty_desc": "Click the button above to upload the first file", + "search_empty_title": "No matching files", + "search_empty_desc": "Try changing the filters or clearing the search box.", + "file_size": "Size", + "file_modified": "Modified", + "download_button": "Download", + "delete_button": "Delete", + "delete_confirm": "Are you sure you want to delete file", + "delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!", + "delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!", + "upload_error_json": "Only JSON files are allowed", + "upload_error_size": "File size cannot exceed {{maxSize}}", + "upload_success": "File uploaded successfully", + "download_success": "File downloaded successfully", + "delete_success": "File deleted successfully", + "delete_all_success": "Successfully deleted", + "delete_filtered_success": "Deleted {{count}} {{type}} auth files successfully", + "delete_filtered_partial": "{{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed", + "delete_filtered_none": "No deletable auth files under the current filter ({{type}})", + "files_count": "files", + "pagination_prev": "Previous", + "pagination_next": "Next", + "pagination_info": "Page {{current}} / {{total}} · {{count}} files", + "search_label": "Search configs", + "search_placeholder": "Filter by name, type, or provider", + "page_size_label": "Per page", + "page_size_unit": "items", + "view_mode_paged": "Paged", + "view_mode_all": "Show all", + "too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.", + "filter_all": "All", + "filter_qwen": "Qwen", + "filter_gemini": "Gemini", + "filter_gemini-cli": "GeminiCLI", + "filter_aistudio": "AIStudio", + "filter_claude": "Claude", + "filter_codex": "Codex", + "filter_antigravity": "Antigravity", + "filter_iflow": "iFlow", + "filter_vertex": "Vertex", + "filter_empty": "Empty", + "filter_unknown": "Other", + "type_qwen": "Qwen", + "type_gemini": "Gemini", + "type_gemini-cli": "GeminiCLI", + "type_aistudio": "AIStudio", + "type_claude": "Claude", + "type_codex": "Codex", + "type_antigravity": "Antigravity", + "type_iflow": "iFlow", + "type_vertex": "Vertex", + "type_empty": "Empty", + "type_unknown": "Other", + "type_virtual": "Virtual auth file", + "models_button": "Models", + "models_title": "Supported models", + "models_loading": "Loading model list...", + "models_empty": "No available models for this credential", + "models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.", + "models_unsupported": "This feature is not supported in the current version", + "models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again", + "models_excluded_badge": "Excluded", + "models_excluded_hint": "This model is excluded by OAuth", + "status_toggle_label": "Enabled", + "status_enabled_success": "\"{{name}}\" enabled", + "status_disabled_success": "\"{{name}}\" disabled", + "prefix_proxy_button": "Edit prefix/proxy_url", + "prefix_proxy_loading": "Loading credential...", + "prefix_proxy_source_label": "Credential JSON", + "prefix_label": "prefix", + "proxy_url_label": "proxy_url", + "prefix_placeholder": "", + "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", + "prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.", + "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully", + "card_tools_title": "Tools", + "quota_refresh_single": "Refresh quota", + "quota_refresh_hint": "Refresh quota for this credential only", + "quota_refresh_success": "Quota refreshed for \"{{name}}\"", + "quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}" + }, + "antigravity_quota": { + "title": "Antigravity Quota", + "empty_title": "No Antigravity Auth Files", + "empty_desc": "Upload an Antigravity credential to view remaining quota.", + "idle": "Not loaded. Click Refresh Button.", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "empty_models": "No quota data available", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All" + }, + "codex_quota": { + "title": "Codex Quota", + "empty_title": "No Codex Auth Files", + "empty_desc": "Upload a Codex credential to view quota.", + "idle": "Not loaded. Click Refresh Button.", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "missing_account_id": "Codex credential missing ChatGPT account ID", + "empty_windows": "No quota data available", + "no_access": "This credential has no Codex access (plan: free).", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All", + "primary_window": "5-hour limit", + "secondary_window": "Weekly limit", + "code_review_primary_window": "Code review 5-hour limit", + "code_review_secondary_window": "Code review weekly limit", + "plan_label": "Plan", + "plan_plus": "Plus", + "plan_team": "Team", + "plan_free": "Free" + }, + "gemini_cli_quota": { + "title": "Gemini CLI Quota", + "empty_title": "No Gemini CLI Auth Files", + "empty_desc": "Upload a Gemini CLI credential to view remaining quota.", + "idle": "Not loaded. Click Refresh Button.", + "loading": "Loading quota...", + "load_failed": "Failed to load quota: {{message}}", + "missing_auth_index": "Auth file missing auth_index", + "missing_project_id": "Gemini CLI credential missing project ID", + "empty_buckets": "No quota data available", + "refresh_button": "Refresh Quota", + "fetch_all": "Fetch All", + "remaining_amount": "Remaining {{count}}" + }, + "vertex_import": { + "title": "Vertex JSON Login", + "description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.", + "location_label": "Region (optional)", + "location_placeholder": "us-central1", + "location_hint": "Leave empty to use the default region us-central1.", + "file_label": "Service account key JSON", + "file_hint": "Only Google Cloud service account key JSON files are accepted.", + "file_placeholder": "No file selected", + "choose_file": "Choose File", + "import_button": "Import Vertex Credential", + "file_required": "Select a .json credential file first", + "success": "Vertex credential imported successfully", + "result_title": "Credential saved", + "result_project": "Project ID", + "result_email": "Service account", + "result_location": "Region", + "result_file": "Persisted file" + }, + "oauth_excluded": { + "title": "OAuth Excluded Models", + "description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.", + "add": "Add Exclusion", + "add_title": "Add provider exclusion", + "edit_title": "Edit exclusions for {{provider}}", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "provider_label": "Provider", + "provider_auto": "Follow current filter", + "provider_placeholder": "e.g. gemini-cli", + "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", + "models_label": "Models to exclude", + "models_loading": "Loading models...", + "models_unsupported": "Current CPA version does not support fetching model lists.", + "models_loaded": "{{count}} models loaded. Check the models to exclude.", + "no_models_available": "No models available for this provider.", + "save": "Save/Update", + "saving": "Saving...", + "save_success": "Excluded models updated", + "save_failed": "Failed to update excluded models", + "delete": "Delete Provider", + "delete_confirm": "Delete the exclusion list for {{provider}}?", + "delete_success": "Exclusion list removed", + "delete_failed": "Failed to delete exclusion list", + "deleting": "Deleting...", + "no_models": "No excluded models", + "model_count": "{{count}} models excluded", + "list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.", + "list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.", + "disconnected": "Connect to the server to view exclusions", + "load_failed": "Failed to load exclusion list", + "provider_required": "Please enter a provider first", + "scope_all": "Scope: All providers", + "scope_provider": "Scope: {{provider}}", + "upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", + "upgrade_required_title": "Please upgrade CLI Proxy API", + "upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." + }, + "oauth_model_alias": { + "title": "OAuth Model Aliases", + "add": "Add Alias", + "add_title": "Add provider model aliases", + "provider_label": "Provider", + "provider_placeholder": "e.g. gemini-cli / vertex", + "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", + "model_source_loading": "Loading models...", + "model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).", + "model_source_loaded": "{{count}} models loaded. Use the dropdown in 'Source model name', or type custom values. Saving an empty list removes that provider. Enable 'Keep original' to keep the original name while adding the alias.", + "alias_label": "Model aliases", + "alias_name_placeholder": "Source model name", + "alias_placeholder": "Alias (required)", + "alias_fork_label": "Keep original", + "add_alias": "Add alias", + "save": "Save/Update", + "save_success": "Model aliases updated", + "save_failed": "Failed to update model aliases", + "delete": "Delete Provider", + "delete_confirm": "Delete model aliases for {{provider}}?", + "delete_link_title": "Unlink mapping", + "delete_link_confirm": "Unlink mapping from {{sourceModel}} ({{provider}}) to alias {{alias}}?", + "delete_alias_title": "Delete Alias", + "delete_alias_confirm": "Delete alias {{alias}} and unmap all associated models?", + "delete_success": "Model aliases removed", + "delete_failed": "Failed to delete model aliases", + "no_models": "No model aliases", + "model_count": "{{count}} aliases", + "list_empty_all": "No model aliases yet—use “Add Alias” to create one.", + "chart_title": "All mappings overview", + "diagram_providers": "Providers", + "diagram_source_models": "Source Models", + "diagram_aliases": "Aliases", + "diagram_expand": "Expand", + "diagram_collapse": "Collapse", + "diagram_add_alias": "Add Alias", + "diagram_rename": "Rename", + "diagram_rename_alias_title": "Rename alias", + "diagram_rename_alias_label": "New alias name", + "diagram_rename_placeholder": "Enter alias name...", + "diagram_delete_link": "Unlink from {{provider}} / {{name}}", + "diagram_delete_alias": "Delete alias", + "diagram_please_enter_alias": "Please enter an alias name.", + "diagram_alias_exists": "This alias already exists.", + "diagram_add_alias_title": "Add alias", + "diagram_add_alias_label": "Alias name", + "diagram_add_placeholder": "Enter new alias name...", + "diagram_rename_btn": "Rename", + "diagram_add_btn": "Add", + "diagram_settings": "Settings", + "diagram_settings_title": "Alias settings — {{alias}}", + "diagram_settings_source_title": "Source model settings", + "diagram_settings_empty": "No mappings for this alias yet.", + "diagram_tap_hint": "On touch devices: tap a source model, then tap an alias to link.", + "view_mode": "View mode", + "view_mode_diagram": "Diagram", + "view_mode_list": "List", + "provider_required": "Please enter a provider first", + "upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", + "upgrade_required_title": "Please upgrade CLI Proxy API", + "upgrade_required_desc": "The current server does not support the OAuth model aliases API. Please upgrade to the latest CLI Proxy API (CPA) version." + }, + "auth_login": { + "codex_oauth_title": "Codex OAuth", + "codex_oauth_button": "Start Codex Login", + "codex_oauth_hint": "Login to Codex service through OAuth flow, automatically obtain and save authentication files.", + "codex_oauth_url_label": "Authorization URL:", + "codex_open_link": "Open Link", + "codex_copy_link": "Copy Link", + "codex_oauth_status_waiting": "Waiting for authentication...", + "codex_oauth_status_success": "Authentication successful!", + "codex_oauth_status_error": "Authentication failed:", + "codex_oauth_start_error": "Failed to start Codex OAuth:", + "codex_oauth_polling_error": "Failed to check authentication status:", + "anthropic_oauth_title": "Anthropic OAuth", + "anthropic_oauth_button": "Start Anthropic Login", + "anthropic_oauth_hint": "Login to Anthropic (Claude) service through OAuth flow, automatically obtain and save authentication files.", + "anthropic_oauth_url_label": "Authorization URL:", + "anthropic_open_link": "Open Link", + "anthropic_copy_link": "Copy Link", + "anthropic_oauth_status_waiting": "Waiting for authentication...", + "anthropic_oauth_status_success": "Authentication successful!", + "anthropic_oauth_status_error": "Authentication failed:", + "anthropic_oauth_start_error": "Failed to start Anthropogenic OAuth:", + "anthropic_oauth_polling_error": "Failed to check authentication status:", + "antigravity_oauth_title": "Antigravity OAuth", + "antigravity_oauth_button": "Start Antigravity Login", + "antigravity_oauth_hint": "Login to Antigravity service (Google account) through OAuth flow, automatically obtain and save authentication files.", + "antigravity_oauth_url_label": "Authorization URL:", + "antigravity_open_link": "Open Link", + "antigravity_copy_link": "Copy Link", + "antigravity_oauth_status_waiting": "Waiting for authentication...", + "antigravity_oauth_status_success": "Authentication successful!", + "antigravity_oauth_status_error": "Authentication failed:", + "antigravity_oauth_start_error": "Failed to start Antigravity OAuth:", + "antigravity_oauth_polling_error": "Failed to check authentication status:", + "gemini_cli_oauth_title": "Gemini CLI OAuth", + "gemini_cli_oauth_button": "Start Gemini CLI Login", + "gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.", + "gemini_cli_project_id_label": "Google Cloud Project ID (Optional):", + "gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project", + "gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.", + "gemini_cli_project_id_required": "Please enter a Google Cloud project ID.", + "gemini_cli_oauth_url_label": "Authorization URL:", + "gemini_cli_open_link": "Open Link", + "gemini_cli_copy_link": "Copy Link", + "gemini_cli_oauth_status_waiting": "Waiting for authentication...", + "gemini_cli_oauth_status_success": "Authentication successful!", + "gemini_cli_oauth_status_error": "Authentication failed:", + "gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:", + "gemini_cli_oauth_polling_error": "Failed to check authentication status:", + "qwen_oauth_title": "Qwen OAuth", + "qwen_oauth_button": "Start Qwen Login", + "qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.", + "qwen_oauth_url_label": "Authorization URL:", + "qwen_open_link": "Open Link", + "qwen_copy_link": "Copy Link", + "qwen_oauth_status_waiting": "Waiting for authentication...", + "qwen_oauth_status_success": "Authentication successful!", + "qwen_oauth_status_error": "Authentication failed:", + "qwen_oauth_start_error": "Failed to start Qwen OAuth:", + "qwen_oauth_polling_error": "Failed to check authentication status:", + "oauth_callback_label": "Callback URL", + "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", + "oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.", + "oauth_callback_button": "Submit Callback URL", + "oauth_callback_required": "Please paste the full redirect URL first.", + "oauth_callback_success": "Callback URL submitted. Continue waiting for authentication.", + "oauth_callback_error": "Failed to submit callback URL:", + "oauth_callback_upgrade_hint": "Please update CLI Proxy API or check the connection.", + "oauth_callback_status_success": "Callback URL submitted, waiting for authentication...", + "oauth_callback_status_error": "Callback URL submission failed:", + "missing_state": "Unable to retrieve authentication state parameter", + "iflow_oauth_title": "iFlow OAuth", + "iflow_oauth_button": "Start iFlow Login", + "iflow_oauth_hint": "Login to iFlow service through OAuth flow, automatically obtain and save authentication files.", + "iflow_oauth_url_label": "Authorization URL:", + "iflow_open_link": "Open Link", + "iflow_copy_link": "Copy Link", + "iflow_oauth_status_waiting": "Waiting for authentication...", + "iflow_oauth_status_success": "Authentication successful!", + "iflow_oauth_status_error": "Authentication failed:", + "iflow_oauth_start_error": "Failed to start iFlow OAuth:", + "iflow_oauth_polling_error": "Failed to check authentication status:", + "iflow_cookie_title": "iFlow Cookie Login", + "iflow_cookie_label": "Cookie Value:", + "iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=", + "iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.", + "iflow_cookie_key_hint": "Note: Create a key on the platform first.", + "iflow_cookie_button": "Submit Cookie Login", + "iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.", + "iflow_cookie_status_error": "Cookie login failed:", + "iflow_cookie_status_duplicate": "Duplicate config:", + "iflow_cookie_start_error": "Failed to submit cookie login:", + "iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.", + "iflow_cookie_required": "Please provide the Cookie value first.", + "iflow_cookie_result_title": "Cookie Login Result", + "iflow_cookie_result_email": "Account", + "iflow_cookie_result_expired": "Expires At", + "iflow_cookie_result_path": "Saved Path", + "iflow_cookie_result_type": "Type", + "remote_access_disabled": "This login method is not available for remote access. Please access from localhost." + }, + "usage_stats": { + "title": "Usage Statistics", + "total_requests": "Total Requests", + "success_requests": "Success Requests", + "failed_requests": "Failed Requests", + "total_tokens": "Total Tokens", + "cached_tokens": "Cached Tokens", + "reasoning_tokens": "Reasoning Tokens", + "rpm_30m": "RPM", + "tpm_30m": "TPM", + "rate_30m": "Rate (last 30 min)", + "model_name": "Model Name", + "model_price_settings": "Model Pricing Settings", + "saved_prices": "Saved Prices", + "requests_trend": "Request Trends", + "tokens_trend": "Token Usage Trends", + "api_details": "API Details", + "by_hour": "By Hour", + "by_day": "By Day", + "refresh": "Refresh", + "export": "Export", + "import": "Import", + "export_success": "Usage export downloaded", + "import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}", + "import_invalid": "Invalid usage export file", + "chart_line_label_1": "Line 1", + "chart_line_label_2": "Line 2", + "chart_line_label_3": "Line 3", + "chart_line_label_4": "Line 4", + "chart_line_label_5": "Line 5", + "chart_line_label_6": "Line 6", + "chart_line_label_7": "Line 7", + "chart_line_label_8": "Line 8", + "chart_line_label_9": "Line 9", + "chart_line_hidden": "Hide", + "chart_line_actions_label": "Lines to display", + "chart_line_add": "Add line", + "chart_line_all": "All", + "chart_line_delete": "Delete line", + "chart_line_hint": "Show up to 9 model lines at once", + "no_data": "No Data Available", + "loading_error": "Loading Failed", + "api_endpoint": "API Endpoint", + "requests_count": "Request Count", + "tokens_count": "Token Count", + "models": "Model Statistics", + "success_rate": "Success Rate", + "total_cost": "Total Cost", + "total_cost_hint": "Based on configured model pricing", + "model_price_title": "Model Pricing", + "model_price_reset": "Clear Prices", + "model_price_model_label": "Model", + "model_price_select_placeholder": "Choose a model", + "model_price_select_hint": "Models come from usage details", + "model_price_prompt": "Prompt price", + "model_price_completion": "Completion price", + "model_price_cache": "Cache price", + "model_price_save": "Save Price", + "model_price_empty": "No model prices set", + "model_price_model": "Model", + "model_price_saved": "Model price saved", + "model_price_model_required": "Please choose a model to set pricing", + "cost_trend": "Cost Overview", + "cost_axis_label": "Cost ($)", + "cost_need_price": "Set a model price to view cost stats", + "cost_need_usage": "No usage data available to calculate cost", + "cost_no_data": "No cost data yet" + }, + "stats": { + "success": "Success", + "failure": "Failure" + }, + "logs": { + "title": "Logs Viewer", + "refresh_button": "Refresh Logs", + "clear_button": "Clear Logs", + "download_button": "Download Logs", + "error_log_button": "Select Error Log", + "error_logs_modal_title": "Error Request Logs", + "error_logs_description": "Pick an error request log file to download (only generated when request logging is off).", + "error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.", + "error_logs_empty": "No error request log files found", + "error_logs_load_error": "Failed to load error log list", + "error_logs_size": "Size", + "error_logs_modified": "Last modified", + "error_logs_download": "Download", + "error_log_download_success": "Error log downloaded successfully", + "request_log_download_title": "Download Request Log", + "request_log_download_confirm": "Download request log for ID {{id}}?", + "request_log_download_success": "Request log downloaded successfully", + "empty_title": "No Logs Available", + "empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here", + "log_content": "Log Content", + "loading": "Loading logs...", + "load_error": "Failed to load logs", + "clear_confirm": "Are you sure you want to clear all logs? This action cannot be undone!", + "clear_success": "Logs cleared successfully", + "download_success": "Logs downloaded successfully", + "auto_refresh": "Auto Refresh", + "auto_refresh_enabled": "Auto refresh enabled", + "auto_refresh_disabled": "Auto refresh disabled", + "load_more_hint": "Scroll up to load more", + "hidden_lines": "Hidden: {{count}} lines", + "loaded_lines": "Loaded: {{count}} lines", + "filtered_lines": "Filtered: {{count}} lines", + "hide_management_logs": "Hide {{prefix}} logs", + "show_raw_logs": "Show Raw Logs", + "show_raw_logs_hint": "Show original log text for easier multi-line copy", + "search_placeholder": "Search logs by content or keyword", + "search_empty_title": "No matching logs found", + "search_empty_desc": "Try a different keyword or clear the filters.", + "double_click_copy_hint": "Double-click to copy raw log line", + "copy_success": "Log copied to clipboard", + "copy_failed": "Copy failed", + "lines": "lines", + "removed": "Filtered", + "upgrade_required_title": "Please Upgrade CLI Proxy API", + "upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature." + }, + "config_management": { + "title": "Config Panel", + "editor_title": "Configuration File", + "reload": "Reload", + "save": "Save", + "description": "Edit config.yaml via visual editor or source file", + "status_idle": "Waiting for action", + "status_loading": "Loading configuration...", + "status_loaded": "Configuration loaded", + "status_dirty": "Unsaved changes", + "status_disconnected": "Connect to the server to load the configuration", + "status_load_failed": "Load failed", + "status_saving": "Saving configuration...", + "status_saved": "Configuration saved", + "status_save_failed": "Save failed", + "save_success": "Configuration saved successfully", + "error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.", + "editor_placeholder": "key: value", + "search_placeholder": "Search config...", + "search_button": "Search", + "search_no_results": "No results", + "search_prev": "Previous", + "search_next": "Next", + "tabs": { + "visual": "Visual Editor", + "source": "Source File Editor" + }, + "visual": { + "sections": { + "server": { + "title": "Server Configuration", + "description": "Basic server settings", + "host": "Host Address", + "port": "Port" + }, + "tls": { + "title": "TLS/SSL Configuration", + "description": "HTTPS secure connection settings", + "enable": "Enable TLS", + "enable_desc": "Enable HTTPS secure connection", + "cert": "Certificate File Path", + "key": "Private Key File Path" + }, + "remote": { + "title": "Remote Management", + "description": "Remote access and control panel settings", + "allow_remote": "Allow Remote Access", + "allow_remote_desc": "Allow management access from other hosts", + "disable_panel": "Disable Control Panel", + "disable_panel_desc": "Disable the built-in web control panel", + "secret_key": "Management Key", + "secret_key_placeholder": "Set management key", + "panel_repo": "Panel Repository" + }, + "auth": { + "title": "Authentication Configuration", + "description": "API keys and authentication directory settings", + "auth_dir": "Auth Directory (auth-dir)", + "auth_dir_hint": "Directory path for authentication files (supports ~)" + }, + "system": { + "title": "System Configuration", + "description": "Debug, logging, statistics, and performance settings", + "debug": "Debug Mode", + "debug_desc": "Enable verbose debug logging", + "commercial_mode": "Commercial Mode", + "commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency", + "logging_to_file": "Log to File", + "logging_to_file_desc": "Save logs to rotating files", + "usage_statistics": "Usage Statistics", + "usage_statistics_desc": "Collect usage statistics", + "logs_max_size": "Log File Size Limit (MB)", + "usage_retention_days": "Usage Records Retention Days", + "usage_retention_hint": "0 means no limit (no cleanup)" + }, + "network": { + "title": "Network Configuration", + "description": "Proxy, retry, and routing settings", + "proxy_url": "Proxy URL", + "request_retry": "Request Retry Count", + "max_retry_interval": "Max Retry Interval (seconds)", + "routing_strategy": "Routing Strategy", + "routing_strategy_hint": "Select credential selection strategy", + "strategy_round_robin": "Round Robin", + "strategy_fill_first": "Fill First", + "force_model_prefix": "Force Model Prefix", + "force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix", + "ws_auth": "WebSocket Authentication", + "ws_auth_desc": "Enable WebSocket authentication (/v1/ws)" + }, + "quota": { + "title": "Quota Fallback", + "description": "Fallback strategy when quota is exceeded", + "switch_project": "Switch Project", + "switch_project_desc": "Automatically switch to another project when quota is exceeded", + "switch_preview_model": "Switch to Preview Model", + "switch_preview_model_desc": "Switch to preview model version when quota is exceeded" + }, + "streaming": { + "title": "Streaming Configuration", + "description": "Keepalive and bootstrap retry settings", + "keepalive_seconds": "Keepalive Seconds", + "keepalive_hint": "Set to 0 or leave empty to disable keepalive", + "bootstrap_retries": "Bootstrap Retries", + "bootstrap_hint": "Number of retries during stream startup (before first byte)", + "nonstream_keepalive": "Non-stream Keepalive Interval (seconds)", + "nonstream_keepalive_hint": "Send blank lines every N seconds for non-streaming responses to prevent idle timeout, set to 0 or leave empty to disable", + "disabled": "Disabled" + }, + "payload": { + "title": "Payload Configuration", + "description": "Default values, override rules, and filter rules", + "default_rules": "Default Rules", + "default_rules_desc": "Use these default values when parameters are not specified in the request", + "override_rules": "Override Rules", + "override_rules_desc": "Force override parameter values in the request", + "filter_rules": "Filter Rules", + "filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)" + } + }, + "api_keys": { + "label": "API Keys List (api-keys)", + "add": "Add API Key", + "empty": "No API keys", + "hint": "Each entry represents an API key (consistent with 'API Key Management' page style)", + "edit_title": "Edit API Key", + "add_title": "Add API Key", + "input_label": "API Key", + "input_placeholder": "Paste your API key", + "input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface", + "error_empty": "Please enter an API key", + "error_invalid": "API key contains invalid characters" + }, + "payload_rules": { + "rule": "Rule", + "models": "Applicable Models", + "model_name": "Model Name", + "provider_type": "Provider Type", + "add_model": "Add Model", + "params": "Parameter Settings", + "remove_params": "Remove Parameters", + "json_path": "JSON Path (e.g., temperature)", + "json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget", + "param_type": "Parameter Type", + "add_param": "Add Parameter", + "no_rules": "No rules", + "add_rule": "Add Rule", + "value_string": "String value", + "value_number": "Number value (e.g., 0.7)", + "value_boolean": "true or false", + "value_json": "JSON value", + "value_default": "Value" + }, + "common": { + "edit": "Edit", + "delete": "Delete", + "cancel": "Cancel", + "update": "Update", + "add": "Add" + } + } + }, + "quota_management": { + "title": "Quota Management", + "description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.", + "refresh_files": "Refresh auth files", + "refresh_files_and_quota": "Refresh files & quota" + }, + "system_info": { + "title": "Management Center Info", + "connection_status_title": "Connection Status", + "api_status_label": "API Status:", + "config_status_label": "Config Status:", + "last_update_label": "Last Update:", + "cache_data": "Cache Data", + "real_time_data": "Real-time Data", + "not_loaded": "Not Loaded", + "seconds_ago": "seconds ago", + "models_title": "Available Models", + "models_desc": "Shows the /models response and uses saved API keys for auth automatically.", + "models_loading": "Loading available models...", + "models_empty": "No models returned by /models", + "models_error": "Failed to load model list", + "models_count": "{{count}} available models", + "version_check_title": "Update Check", + "version_check_desc": "Call the /latest-version endpoint to compare with the server version and see if an update is available.", + "version_current_label": "Current version", + "version_latest_label": "Latest version", + "version_check_button": "Check for updates", + "version_check_idle": "Click to check for updates", + "version_checking": "Checking for the latest version...", + "version_update_available": "An update is available: {{version}}", + "version_is_latest": "You are on the latest version", + "version_check_error": "Update check failed", + "version_current_missing": "Server version is unavailable; cannot compare", + "version_unknown": "Unknown", + "quick_links_title": "Quick Links", + "quick_links_desc": "Access project repositories and documentation for help and updates.", + "link_main_repo": "Main Repository", + "link_main_repo_desc": "CLI Proxy API core program source code", + "link_webui_repo": "WebUI Repository", + "link_webui_repo_desc": "Management Center frontend source code", + "link_docs": "Documentation", + "link_docs_desc": "Usage tutorials and configuration guides", + "clear_login_title": "Local Login Data", + "clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.", + "clear_login_button": "Clear login data", + "clear_login_confirm": "Clear local login data and sign out now?" + }, + "notification": { + "debug_updated": "Debug settings updated", + "proxy_updated": "Proxy settings updated", + "proxy_cleared": "Proxy settings cleared", + "retry_updated": "Retry settings updated", + "quota_switch_project_updated": "Project switch settings updated", + "quota_switch_preview_updated": "Preview model switch settings updated", + "usage_statistics_updated": "Usage statistics settings updated", + "logging_to_file_updated": "Logging settings updated", + "logs_max_total_size_updated": "Log size limit updated", + "request_log_updated": "Request logging setting updated", + "force_model_prefix_updated": "Model prefix setting updated", + "ws_auth_updated": "WebSocket authentication setting updated", + "routing_strategy_updated": "Routing strategy updated", + "login_storage_cleared": "Local login data cleared", + "api_key_added": "API key added successfully", + "api_key_updated": "API key updated successfully", + "api_key_deleted": "API key deleted successfully", + "api_key_invalid_chars": "API key can only contain letters, numbers, and symbols", + "gemini_key_added": "Gemini key added successfully", + "gemini_key_updated": "Gemini key updated successfully", + "gemini_key_deleted": "Gemini key deleted successfully", + "gemini_multi_input_required": "Please enter at least one Gemini key", + "gemini_multi_failed": "Gemini bulk add failed", + "gemini_multi_summary": "Gemini bulk add finished: {{success}} added, {{skipped}} skipped, {{failed}} failed", + "codex_config_added": "Codex configuration added successfully", + "codex_config_updated": "Codex configuration updated successfully", + "codex_config_deleted": "Codex configuration deleted successfully", + "codex_base_url_required": "Please enter the Codex Base URL", + "claude_config_added": "Claude configuration added successfully", + "claude_config_updated": "Claude configuration updated successfully", + "claude_config_deleted": "Claude configuration deleted successfully", + "vertex_config_added": "Vertex configuration added successfully", + "vertex_config_updated": "Vertex configuration updated successfully", + "vertex_config_deleted": "Vertex configuration deleted successfully", + "vertex_base_url_required": "Please enter the Vertex Base URL", + "config_enabled": "Configuration enabled", + "config_disabled": "Configuration disabled", + "field_required": "Required fields cannot be empty", + "openai_provider_required": "Please fill in provider name and Base URL", + "openai_provider_added": "OpenAI provider added successfully", + "openai_provider_updated": "OpenAI provider updated successfully", + "openai_provider_deleted": "OpenAI provider deleted successfully", + "ampcode_updated": "Ampcode configuration updated", + "ampcode_upstream_api_key_cleared": "Ampcode upstream API key override cleared", + "openai_model_name_required": "Model name is required", + "openai_test_url_required": "Please provide a valid Base URL before testing", + "openai_test_key_required": "Please add at least one API key before testing", + "openai_test_model_required": "Please select a model to test", + "data_refreshed": "Data refreshed successfully", + "connection_required": "Please establish connection first", + "refresh_failed": "Refresh failed", + "update_failed": "Update failed", + "add_failed": "Add failed", + "delete_failed": "Delete failed", + "upload_failed": "Upload failed", + "download_failed": "Download failed", + "login_failed": "Login failed", + "please_enter": "Please enter", + "please_fill": "Please fill", + "provider_name_url": "provider name and Base URL", + "api_key": "API key", + "gemini_api_key": "Gemini API key", + "codex_api_key": "Codex API key", + "claude_api_key": "Claude API key", + "link_copied": "Link copied to clipboard" + }, + "language": { + "switch": "Language", + "chinese": "中文", + "english": "English" + }, + "theme": { + "switch": "Theme", + "light": "Light", + "dark": "Dark", + "switch_to_light": "Switch to light mode", + "switch_to_dark": "Switch to dark mode", + "auto": "Follow system" + }, + "sidebar": { + "toggle_expand": "Expand sidebar", + "toggle_collapse": "Collapse sidebar" + }, + "footer": { + "api_version": "CLI Proxy API Version", + "build_date": "Build Time", + "version": "Management UI Version", + "author": "Author" + } +} diff --git a/src/types/common.ts b/src/types/common.ts index c41d23b..f78aa62 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -4,7 +4,7 @@ export type Theme = 'light' | 'dark' | 'auto'; -export type Language = 'zh-CN' | 'en'; +export type Language = 'zh-CN' | 'en' | 'ru'; export type NotificationType = 'info' | 'success' | 'warning' | 'error'; diff --git a/src/utils/language.ts b/src/utils/language.ts index e4775cc..1975384 100644 --- a/src/utils/language.ts +++ b/src/utils/language.ts @@ -5,11 +5,11 @@ const parseStoredLanguage = (value: string): Language | null => { try { const parsed = JSON.parse(value); const candidate = parsed?.state?.language ?? parsed?.language ?? parsed; - if (candidate === 'zh-CN' || candidate === 'en') { + if (candidate === 'zh-CN' || candidate === 'en' || candidate === 'ru') { return candidate; } } catch { - if (value === 'zh-CN' || value === 'en') { + if (value === 'zh-CN' || value === 'en' || value === 'ru') { return value; } } @@ -36,7 +36,10 @@ const getBrowserLanguage = (): Language => { return 'zh-CN'; } const raw = navigator.languages?.[0] || navigator.language || 'zh-CN'; - return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'; + const lower = raw.toLowerCase(); + if (lower.startsWith('zh')) return 'zh-CN'; + if (lower.startsWith('ru')) return 'ru'; + return 'en'; }; export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage(); From ad6a3bd7327538a0bc8a0e357418a905f537e820 Mon Sep 17 00:00:00 2001 From: Chebotov Nickolay Date: Fri, 6 Feb 2026 12:26:46 +0300 Subject: [PATCH 02/24] feat: expand Russian localization --- package-lock.json | 16 - src/i18n/locales/en.json | 3 +- src/i18n/locales/ru.json | 1777 ++++++++++++++++---------------- src/i18n/locales/zh-CN.json | 3 +- src/pages/LoginPage.tsx | 11 +- src/stores/useLanguageStore.ts | 6 +- 6 files changed, 906 insertions(+), 910 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ce56de..b8645c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -467,7 +466,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1933,7 +1931,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2021,7 +2018,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -2339,7 +2335,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2551,7 +2546,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2816,7 +2810,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3293,7 +3286,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3623,7 +3615,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3730,7 +3721,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3748,7 +3738,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3857,7 +3846,6 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4040,7 +4028,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4117,7 +4104,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4247,7 +4233,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -4275,7 +4260,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6b90898..d5bbd13 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1090,7 +1090,8 @@ "language": { "switch": "Language", "chinese": "中文", - "english": "English" + "english": "English", + "russian": "Русский" }, "theme": { "switch": "Theme", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e627273..54954df 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -107,272 +107,272 @@ "system_info": "Информация системы" }, "dashboard": { - "title": "Dashboard", - "subtitle": "Welcome to CLI Proxy API Management Center", - "openai_providers": "OpenAI Providers", - "quick_actions": "Quick Actions", - "current_config": "Current Configuration", - "management_keys": "Management Keys", + "title": "Панель управления", + "subtitle": "Добро пожаловать в CLI Proxy API Management Center", + "openai_providers": "Поставщики OpenAI", + "quick_actions": "Быстрые действия", + "current_config": "Текущая конфигурация", + "management_keys": "Ключи управления", "provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}", - "oauth_credentials": "OAuth Credentials", - "usage_overview": "Usage Overview", - "total_requests": "Total Requests", - "total_tokens": "Total Tokens", - "rpm_30min": "RPM (30min)", - "tpm_30min": "TPM (30min)", - "models_used": "Models Used", - "no_usage_data": "No usage data available", - "view_detailed_usage": "View Detailed Stats", - "edit_settings": "Edit Settings", - "available_models": "Available Models", - "available_models_desc": "Total models from all providers" + "oauth_credentials": "Учётные данные OAuth", + "usage_overview": "Обзор использования", + "total_requests": "Всего запросов", + "total_tokens": "Всего токенов", + "rpm_30min": "RPM (30 мин)", + "tpm_30min": "TPM (30 мин)", + "models_used": "Используемые модели", + "no_usage_data": "Данные об использовании отсутствуют", + "view_detailed_usage": "Просмотреть детальную статистику", + "edit_settings": "Изменить настройки", + "available_models": "Доступные модели", + "available_models_desc": "Всего моделей от всех провайдеров" }, "basic_settings": { - "title": "Basic Settings", - "debug_title": "Debug Mode", - "debug_enable": "Enable Debug Mode", - "proxy_title": "Proxy Settings", - "proxy_url_label": "Proxy URL:", - "proxy_url_placeholder": "e.g.: socks5://user:pass@127.0.0.1:1080/", - "proxy_update": "Update", - "proxy_clear": "Clear", - "retry_title": "Request Retry", - "retry_count_label": "Retry Count:", - "retry_update": "Update", - "quota_title": "Quota Exceeded Behavior", - "quota_switch_project": "Auto Switch Project", - "quota_switch_preview": "Switch to Preview Model", - "usage_statistics_title": "Usage Statistics", - "usage_statistics_enable": "Enable usage statistics", - "logging_title": "Logging", - "logging_to_file_enable": "Enable logging to file", - "logs_max_total_size_title": "Log Size Limit", - "logs_max_total_size_label": "Total log size cap (MB):", - "logs_max_total_size_hint": "Set to 0 to disable the limit.", - "logs_max_total_size_update": "Update", - "request_log_title": "Request Logging", - "request_log_enable": "Enable request logging", - "request_log_warning": "Keep this off unless you need detailed troubleshooting.", - "force_model_prefix_enable": "Force model prefix", - "ws_auth_title": "WebSocket Authentication", - "ws_auth_enable": "Require auth for /ws/*", - "routing_title": "Routing Strategy", - "routing_strategy_label": "Routing strategy:", - "routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.", - "routing_strategy_update": "Update", - "routing_strategy_round_robin": "round-robin (cycle)", - "routing_strategy_fill_first": "fill-first (prioritize)" + "title": "Основные настройки", + "debug_title": "Режим отладки", + "debug_enable": "Включить режим отладки", + "proxy_title": "Настройки прокси", + "proxy_url_label": "URL прокси:", + "proxy_url_placeholder": "например: socks5://user:pass@127.0.0.1:1080/", + "proxy_update": "Обновить", + "proxy_clear": "Очистить", + "retry_title": "Повтор запросов", + "retry_count_label": "Количество повторов:", + "retry_update": "Обновить", + "quota_title": "Поведение при превышении квоты", + "quota_switch_project": "Автоматически переключать проект", + "quota_switch_preview": "Переключаться на preview-модель", + "usage_statistics_title": "Статистика использования", + "usage_statistics_enable": "Включить статистику использования", + "logging_title": "Журналирование", + "logging_to_file_enable": "Включить журналирование в файл", + "logs_max_total_size_title": "Лимит размера журналов", + "logs_max_total_size_label": "Максимальный общий размер журналов (МБ):", + "logs_max_total_size_hint": "Установите 0, чтобы отключить лимит.", + "logs_max_total_size_update": "Обновить", + "request_log_title": "Журналирование запросов", + "request_log_enable": "Включить журналирование запросов", + "request_log_warning": "Оставьте выключенным, если подробная диагностика не нужна.", + "force_model_prefix_enable": "Включить принудительный префикс модели", + "ws_auth_title": "Аутентификация WebSocket", + "ws_auth_enable": "Требовать аутентификацию для /ws/*", + "routing_title": "Стратегия маршрутизации", + "routing_strategy_label": "Стратегия маршрутизации:", + "routing_strategy_hint": "round-robin циклически перебирает ключи; fill-first отдаёт приоритет первому доступному ключу.", + "routing_strategy_update": "Обновить", + "routing_strategy_round_robin": "round-robin (цикл)", + "routing_strategy_fill_first": "fill-first (приоритет)" }, "api_keys": { - "title": "API Keys Management", - "proxy_auth_title": "Proxy Service Authentication Keys", - "add_button": "Add Key", - "empty_title": "No API Keys", - "empty_desc": "Click the button above to add the first key", - "item_title": "API Key", - "add_modal_title": "Add API Key", - "add_modal_key_label": "API Key:", - "add_modal_key_placeholder": "Please enter API key", - "edit_modal_title": "Edit API Key", - "edit_modal_key_label": "API Key:", - "delete_confirm": "Are you sure you want to delete this API key?" + "title": "Управление API-ключами", + "proxy_auth_title": "Ключи аутентификации прокси-сервиса", + "add_button": "Добавить ключ", + "empty_title": "API-ключи отсутствуют", + "empty_desc": "Нажмите кнопку выше, чтобы добавить первый ключ", + "item_title": "API-ключ", + "add_modal_title": "Добавление API-ключа", + "add_modal_key_label": "API-ключ:", + "add_modal_key_placeholder": "Введите API-ключ", + "edit_modal_title": "Редактирование API-ключа", + "edit_modal_key_label": "API-ключ:", + "delete_confirm": "Удалить этот API-ключ?" }, "ai_providers": { - "title": "AI Providers Configuration", - "gemini_title": "Gemini API Keys", - "gemini_add_button": "Add Key", - "gemini_empty_title": "No Gemini Keys", - "gemini_empty_desc": "Click the button above to add the first key", - "gemini_item_title": "Gemini Key", - "gemini_add_modal_title": "Add Gemini API Key", - "gemini_add_modal_key_label": "API Keys:", - "gemini_add_modal_key_placeholder": "Enter Gemini API key", - "gemini_add_modal_key_hint": "Add keys one by one and optionally specify a Base URL.", - "gemini_keys_add_btn": "Add Key", - "gemini_base_url_label": "Base URL (Optional):", - "gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com", - "gemini_edit_modal_title": "Edit Gemini API Key", - "gemini_edit_modal_key_label": "API Key:", - "gemini_delete_confirm": "Are you sure you want to delete this Gemini key?", - "excluded_models_label": "Excluded models (optional):", - "excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash", - "excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.", - "excluded_models_count": "Excluding {{count}} models", - "prefix_label": "Prefix (Optional):", - "prefix_placeholder": "e.g.: team-a", - "prefix_hint": "When set, call models as prefix/ to target this entry.", - "config_toggle_label": "Enabled", - "config_disabled_badge": "Disabled", - "codex_title": "Codex API Configuration", - "codex_add_button": "Add Configuration", - "codex_empty_title": "No Codex Configuration", - "codex_empty_desc": "Click the button above to add the first configuration", - "codex_item_title": "Codex Configuration", - "codex_add_modal_title": "Add Codex API Configuration", - "codex_add_modal_key_label": "API Key:", - "codex_add_modal_key_placeholder": "Please enter Codex API key", - "codex_add_modal_url_label": "Base URL (Required):", - "codex_add_modal_url_placeholder": "e.g.: https://api.example.com", - "codex_add_modal_proxy_label": "Proxy URL (Optional):", - "codex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", - "codex_edit_modal_title": "Edit Codex API Configuration", - "codex_edit_modal_key_label": "API Key:", - "codex_edit_modal_url_label": "Base URL (Required):", - "codex_edit_modal_proxy_label": "Proxy URL (Optional):", - "codex_delete_confirm": "Are you sure you want to delete this Codex configuration?", - "claude_title": "Claude API Configuration", - "claude_add_button": "Add Configuration", - "claude_empty_title": "No Claude Configuration", - "claude_empty_desc": "Click the button above to add the first configuration", - "claude_item_title": "Claude Configuration", - "claude_add_modal_title": "Add Claude API Configuration", - "claude_add_modal_key_label": "API Key:", - "claude_add_modal_key_placeholder": "Please enter Claude API key", - "claude_add_modal_url_label": "Base URL (Optional):", - "claude_add_modal_url_placeholder": "e.g.: https://api.anthropic.com", - "claude_add_modal_proxy_label": "Proxy URL (Optional):", - "claude_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", - "claude_edit_modal_title": "Edit Claude API Configuration", - "claude_edit_modal_key_label": "API Key:", - "claude_edit_modal_url_label": "Base URL (Optional):", - "claude_edit_modal_proxy_label": "Proxy URL (Optional):", - "claude_delete_confirm": "Are you sure you want to delete this Claude configuration?", - "claude_models_label": "Custom Models (Optional):", - "claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.", - "claude_models_add_btn": "Add Model", - "claude_models_count": "Models Count", - "vertex_title": "Vertex API Configuration", - "vertex_add_button": "Add Configuration", - "vertex_empty_title": "No Vertex Configuration", - "vertex_empty_desc": "Click the button above to add the first configuration", - "vertex_item_title": "Vertex Configuration", - "vertex_add_modal_title": "Add Vertex API Configuration", - "vertex_add_modal_key_label": "API Key:", - "vertex_add_modal_key_placeholder": "Please enter Vertex API key", - "vertex_add_modal_url_label": "Base URL (Required):", - "vertex_add_modal_url_placeholder": "e.g.: https://example.com/api", - "vertex_add_modal_proxy_label": "Proxy URL (Optional):", - "vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080", - "vertex_edit_modal_title": "Edit Vertex API Configuration", - "vertex_edit_modal_key_label": "API Key:", - "vertex_edit_modal_url_label": "Base URL (Required):", - "vertex_edit_modal_proxy_label": "Proxy URL (Optional):", - "vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?", - "vertex_models_label": "Model aliases (alias required):", - "vertex_models_add_btn": "Add Mapping", - "vertex_models_hint": "Each alias needs both the original model and the alias.", - "vertex_models_count": "Alias count", - "ampcode_title": "Amp CLI Integration (ampcode)", - "ampcode_modal_title": "Configure Ampcode", + "title": "Конфигурация AI-провайдеров", + "gemini_title": "API-ключи Gemini", + "gemini_add_button": "Добавить ключ", + "gemini_empty_title": "Ключи Gemini отсутствуют", + "gemini_empty_desc": "Нажмите кнопку выше, чтобы добавить первый ключ", + "gemini_item_title": "Ключ Gemini", + "gemini_add_modal_title": "Добавление API-ключа Gemini", + "gemini_add_modal_key_label": "API-ключи:", + "gemini_add_modal_key_placeholder": "Введите API-ключ Gemini", + "gemini_add_modal_key_hint": "Добавляйте ключи по одному и при необходимости указывайте базовый URL.", + "gemini_keys_add_btn": "Добавить ключ", + "gemini_base_url_label": "Базовый URL (необязательно):", + "gemini_base_url_placeholder": "например: https://generativelanguage.googleapis.com", + "gemini_edit_modal_title": "Редактирование API-ключа Gemini", + "gemini_edit_modal_key_label": "API-ключ:", + "gemini_delete_confirm": "Удалить этот ключ Gemini?", + "excluded_models_label": "Исключённые модели (необязательно):", + "excluded_models_placeholder": "Через запятую или с новой строки, например gemini-1.5-pro, gemini-1.5-flash", + "excluded_models_hint": "Оставьте пустым, чтобы разрешить все модели; значения автоматически обрезаются и дедуплицируются.", + "excluded_models_count": "Исключено моделей: {{count}}", + "prefix_label": "Префикс (необязательно):", + "prefix_placeholder": "например: team-a", + "prefix_hint": "Если задано, обращайтесь к моделям как prefix/, чтобы выбрать эту запись.", + "config_toggle_label": "Включено", + "config_disabled_badge": "Отключено", + "codex_title": "Конфигурация Codex API", + "codex_add_button": "Добавить конфигурацию", + "codex_empty_title": "Конфигурации Codex отсутствуют", + "codex_empty_desc": "Нажмите кнопку выше, чтобы добавить первую конфигурацию", + "codex_item_title": "Конфигурация Codex", + "codex_add_modal_title": "Добавление конфигурации Codex API", + "codex_add_modal_key_label": "API-ключ:", + "codex_add_modal_key_placeholder": "Введите API-ключ Codex", + "codex_add_modal_url_label": "Базовый URL (обязательно):", + "codex_add_modal_url_placeholder": "например: https://api.example.com", + "codex_add_modal_proxy_label": "URL прокси (необязательно):", + "codex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080", + "codex_edit_modal_title": "Редактирование конфигурации Codex API", + "codex_edit_modal_key_label": "API-ключ:", + "codex_edit_modal_url_label": "Базовый URL (обязательно):", + "codex_edit_modal_proxy_label": "URL прокси (необязательно):", + "codex_delete_confirm": "Удалить эту конфигурацию Codex?", + "claude_title": "Конфигурация Claude API", + "claude_add_button": "Добавить конфигурацию", + "claude_empty_title": "Конфигурации Claude отсутствуют", + "claude_empty_desc": "Нажмите кнопку выше, чтобы добавить первую конфигурацию", + "claude_item_title": "Конфигурация Claude", + "claude_add_modal_title": "Добавление конфигурации Claude API", + "claude_add_modal_key_label": "API-ключ:", + "claude_add_modal_key_placeholder": "Введите API-ключ Claude", + "claude_add_modal_url_label": "Базовый URL (необязательно):", + "claude_add_modal_url_placeholder": "например: https://api.anthropic.com", + "claude_add_modal_proxy_label": "URL прокси (необязательно):", + "claude_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080", + "claude_edit_modal_title": "Редактирование конфигурации Claude API", + "claude_edit_modal_key_label": "API-ключ:", + "claude_edit_modal_url_label": "Базовый URL (необязательно):", + "claude_edit_modal_proxy_label": "URL прокси (необязательно):", + "claude_delete_confirm": "Удалить эту конфигурацию Claude?", + "claude_models_label": "Пользовательские модели (необязательно):", + "claude_models_hint": "Оставьте пустым, чтобы разрешить все модели, или добавьте записи name[, alias], чтобы ограничить/переименовать их.", + "claude_models_add_btn": "Добавить модель", + "claude_models_count": "Количество моделей", + "vertex_title": "Конфигурация Vertex API", + "vertex_add_button": "Добавить конфигурацию", + "vertex_empty_title": "Конфигурации Vertex отсутствуют", + "vertex_empty_desc": "Нажмите кнопку выше, чтобы добавить первую конфигурацию", + "vertex_item_title": "Конфигурация Vertex", + "vertex_add_modal_title": "Добавление конфигурации Vertex API", + "vertex_add_modal_key_label": "API-ключ:", + "vertex_add_modal_key_placeholder": "Введите API-ключ Vertex", + "vertex_add_modal_url_label": "Базовый URL (обязательно):", + "vertex_add_modal_url_placeholder": "например: https://example.com/api", + "vertex_add_modal_proxy_label": "URL прокси (необязательно):", + "vertex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080", + "vertex_edit_modal_title": "Редактирование конфигурации Vertex API", + "vertex_edit_modal_key_label": "API-ключ:", + "vertex_edit_modal_url_label": "Базовый URL (обязательно):", + "vertex_edit_modal_proxy_label": "URL прокси (необязательно):", + "vertex_delete_confirm": "Удалить эту конфигурацию Vertex?", + "vertex_models_label": "Псевдонимы моделей (требуется псевдоним):", + "vertex_models_add_btn": "Добавить сопоставление", + "vertex_models_hint": "Каждому псевдониму требуются исходная модель и псевдоним.", + "vertex_models_count": "Количество псевдонимов", + "ampcode_title": "Интеграция Amp CLI (ampcode)", + "ampcode_modal_title": "Настройка Ampcode", "ampcode_upstream_url_label": "Upstream URL", - "ampcode_upstream_url_placeholder": "e.g. https://ampcode.com", - "ampcode_upstream_url_hint": "Optional. Leave empty to use the default/auto-discovered control plane URL.", - "ampcode_upstream_api_key_label": "Upstream API Key (Amp Official)", - "ampcode_upstream_api_key_placeholder": "Enter sk-amp... (leave empty to keep current)", - "ampcode_upstream_api_key_hint": "Optional. Leaving it empty will not change the current Amp official key. Use the button below to clear it.", - "ampcode_upstream_api_key_current": "Current Amp official key: {{key}}", - "ampcode_clear_upstream_api_key": "Clear official key", - "ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?", - "ampcode_force_model_mappings_label": "Force model mappings", - "ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.", - "ampcode_model_mappings_label": "Model mappings (from → to)", - "ampcode_model_mappings_hint": "Rewrites model names in Amp requests. Leave empty to disable mappings.", - "ampcode_model_mappings_add_btn": "Add mapping", - "ampcode_model_mappings_from_placeholder": "from model (source)", - "ampcode_model_mappings_to_placeholder": "to model (target)", - "ampcode_model_mappings_count": "Mappings Count", - "ampcode_mappings_overwrite_confirm": "Existing mappings could not be loaded. Continuing may overwrite or clear them. Continue?", - "openai_title": "OpenAI Compatible Providers", - "openai_add_button": "Add Provider", - "openai_empty_title": "No OpenAI Compatible Providers", - "openai_empty_desc": "Click the button above to add the first provider", - "openai_add_modal_title": "Add OpenAI Compatible Provider", - "openai_add_modal_name_label": "Provider Name:", - "openai_add_modal_name_placeholder": "e.g.: openrouter", - "openai_add_modal_url_label": "Base URL:", - "openai_add_modal_url_placeholder": "e.g.: https://openrouter.ai/api/v1", - "openai_add_modal_keys_label": "API Keys", - "openai_edit_modal_keys_label": "API Keys", - "openai_keys_hint": "Add each key separately with an optional proxy URL to keep things organized.", - "openai_keys_add_btn": "Add Key", - "openai_key_placeholder": "sk-... key", - "openai_proxy_placeholder": "Optional proxy URL (e.g. socks5://...)", - "openai_add_modal_models_label": "Model List (name[, alias] one per line):", - "openai_models_hint": "Example: gpt-4o-mini or moonshotai/kimi-k2:free, kimi-k2", - "openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free", - "openai_model_alias_placeholder": "Model alias (optional)", - "openai_models_add_btn": "Add Model", - "openai_models_fetch_button": "Fetch via /models", - "openai_models_fetch_title": "Pick Models from /models", - "openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", - "openai_models_fetch_url_label": "Request URL", - "openai_models_fetch_refresh": "Refresh", - "openai_models_fetch_loading": "Fetching models from /models...", - "openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.", - "openai_models_fetch_error": "Failed to fetch models", - "openai_models_fetch_back": "Back to edit", - "openai_models_fetch_apply": "Add selected models", - "openai_models_search_label": "Search models", - "openai_models_search_placeholder": "Filter by name, alias, or description", - "openai_models_search_empty": "No models match your search. Try a different keyword.", - "openai_models_fetch_invalid_url": "Please enter a valid Base URL first", - "openai_models_fetch_added": "{{count}} new models added", - "openai_edit_modal_title": "Edit OpenAI Compatible Provider", - "openai_edit_modal_name_label": "Provider Name:", - "openai_edit_modal_url_label": "Base URL:", - "openai_edit_modal_models_label": "Model List (name[, alias] one per line):", - "openai_delete_confirm": "Are you sure you want to delete this OpenAI provider?", - "openai_keys_count": "Keys Count", - "openai_models_count": "Models Count", - "openai_test_title": "Connection Test", - "openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.", - "openai_test_model_placeholder": "Model to test", - "openai_test_action": "Run Test", - "openai_test_running": "Sending test request...", - "openai_test_timeout": "Test request timed out after {{seconds}} seconds.", - "openai_test_success": "Test succeeded. The model responded.", - "openai_test_failed": "Test failed", - "openai_test_select_placeholder": "Choose from current models", - "openai_test_select_empty": "No models configured. Add models first" + "ampcode_upstream_url_placeholder": "например: https://ampcode.com", + "ampcode_upstream_url_hint": "Необязательно. Оставьте пустым, чтобы использовать URL плоскости управления по умолчанию/обнаруженный автоматически.", + "ampcode_upstream_api_key_label": "Upstream API-ключ (официальный Amp)", + "ampcode_upstream_api_key_placeholder": "Введите sk-amp... (оставьте пустым, чтобы сохранить текущий)", + "ampcode_upstream_api_key_hint": "Необязательно. Пустое значение не изменит текущий официальный ключ Amp. Используйте кнопку ниже, чтобы очистить его.", + "ampcode_upstream_api_key_current": "Текущий официальный ключ Amp: {{key}}", + "ampcode_clear_upstream_api_key": "Очистить официальный ключ", + "ampcode_clear_upstream_api_key_confirm": "Очистить upstream API-ключ Ampcode (официальный Amp)?", + "ampcode_force_model_mappings_label": "Принудительно применять сопоставления моделей", + "ampcode_force_model_mappings_hint": "При включении сопоставления переопределяют локальные проверки доступности API-ключей.", + "ampcode_model_mappings_label": "Сопоставления моделей (из → в)", + "ampcode_model_mappings_hint": "Переименовывает модели в запросах Amp. Оставьте пустым, чтобы отключить сопоставления.", + "ampcode_model_mappings_add_btn": "Добавить сопоставление", + "ampcode_model_mappings_from_placeholder": "исходная модель", + "ampcode_model_mappings_to_placeholder": "целевая модель", + "ampcode_model_mappings_count": "Количество сопоставлений", + "ampcode_mappings_overwrite_confirm": "Не удалось загрузить существующие сопоставления. Продолжение может перезаписать или очистить их. Продолжить?", + "openai_title": "Совместимые с OpenAI провайдеры", + "openai_add_button": "Добавить провайдера", + "openai_empty_title": "Провайдеры OpenAI отсутствуют", + "openai_empty_desc": "Нажмите кнопку выше, чтобы добавить первого провайдера", + "openai_add_modal_title": "Добавление совместимого с OpenAI провайдера", + "openai_add_modal_name_label": "Имя провайдера:", + "openai_add_modal_name_placeholder": "например: openrouter", + "openai_add_modal_url_label": "Базовый URL:", + "openai_add_modal_url_placeholder": "например: https://openrouter.ai/api/v1", + "openai_add_modal_keys_label": "API-ключи", + "openai_edit_modal_keys_label": "API-ключи", + "openai_keys_hint": "Добавляйте каждый ключ отдельно с необязательным URL прокси для удобства.", + "openai_keys_add_btn": "Добавить ключ", + "openai_key_placeholder": "ключ вида sk-...", + "openai_proxy_placeholder": "Необязательный URL прокси (например, socks5://...)", + "openai_add_modal_models_label": "Список моделей (name[, alias] по строкам):", + "openai_models_hint": "Пример: gpt-4o-mini или moonshotai/kimi-k2:free, kimi-k2", + "openai_model_name_placeholder": "Имя модели, например moonshotai/kimi-k2:free", + "openai_model_alias_placeholder": "Псевдоним модели (необязательно)", + "openai_models_add_btn": "Добавить модель", + "openai_models_fetch_button": "Получить через /models", + "openai_models_fetch_title": "Выбор моделей из /models", + "openai_models_fetch_hint": "Вызовите эндпоинт /models, используя указанный выше базовый URL, отправив первый API-ключ как Bearer с дополнительными заголовками.", + "openai_models_fetch_url_label": "URL запроса", + "openai_models_fetch_refresh": "Обновить", + "openai_models_fetch_loading": "Получение моделей из /models...", + "openai_models_fetch_empty": "Модели не вернулись. Проверьте эндпоинт или авторизацию.", + "openai_models_fetch_error": "Не удалось получить модели", + "openai_models_fetch_back": "Вернуться к редактированию", + "openai_models_fetch_apply": "Добавить выбранные модели", + "openai_models_search_label": "Поиск моделей", + "openai_models_search_placeholder": "Фильтр по имени, псевдониму или описанию", + "openai_models_search_empty": "Модели по запросу не найдены. Попробуйте другой ключ.", + "openai_models_fetch_invalid_url": "Сначала введите корректный базовый URL", + "openai_models_fetch_added": "Добавлено новых моделей: {{count}}", + "openai_edit_modal_title": "Редактирование совместимого с OpenAI провайдера", + "openai_edit_modal_name_label": "Имя провайдера:", + "openai_edit_modal_url_label": "Базовый URL:", + "openai_edit_modal_models_label": "Список моделей (name[, alias] по строкам):", + "openai_delete_confirm": "Удалить этого провайдера OpenAI?", + "openai_keys_count": "Количество ключей", + "openai_models_count": "Количество моделей", + "openai_test_title": "Тест подключения", + "openai_test_hint": "Отправьте запрос /chat/completions с текущими настройками, чтобы проверить доступность.", + "openai_test_model_placeholder": "Модель для теста", + "openai_test_action": "Запустить тест", + "openai_test_running": "Отправка тестового запроса...", + "openai_test_timeout": "Тестовый запрос превысил тайм-аут {{seconds}} с", + "openai_test_success": "Тест выполнен успешно. Модель ответила.", + "openai_test_failed": "Тест не выполнен", + "openai_test_select_placeholder": "Выберите из текущих моделей", + "openai_test_select_empty": "Модели не настроены. Сначала добавьте модели" }, "auth_files": { - "title": "Auth Files Management", - "title_section": "Auth Files", - "description": "Manage all CLI Proxy JSON auth files here (e.g. Qwen, Gemini, Vertex). Uploading a credential immediately enables the corresponding AI integration.", - "upload_button": "Upload File", - "delete_all_button": "Delete All", - "empty_title": "No Auth Files", - "empty_desc": "Click the button above to upload the first file", - "search_empty_title": "No matching files", - "search_empty_desc": "Try changing the filters or clearing the search box.", - "file_size": "Size", - "file_modified": "Modified", - "download_button": "Download", - "delete_button": "Delete", - "delete_confirm": "Are you sure you want to delete file", - "delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!", - "delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!", - "upload_error_json": "Only JSON files are allowed", - "upload_error_size": "File size cannot exceed {{maxSize}}", - "upload_success": "File uploaded successfully", - "download_success": "File downloaded successfully", - "delete_success": "File deleted successfully", - "delete_all_success": "Successfully deleted", - "delete_filtered_success": "Deleted {{count}} {{type}} auth files successfully", - "delete_filtered_partial": "{{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed", - "delete_filtered_none": "No deletable auth files under the current filter ({{type}})", - "files_count": "files", - "pagination_prev": "Previous", - "pagination_next": "Next", - "pagination_info": "Page {{current}} / {{total}} · {{count}} files", - "search_label": "Search configs", - "search_placeholder": "Filter by name, type, or provider", - "page_size_label": "Per page", - "page_size_unit": "items", - "view_mode_paged": "Paged", - "view_mode_all": "Show all", - "too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.", - "filter_all": "All", + "title": "Управление файлами авторизации", + "title_section": "Файлы авторизации", + "description": "Управляйте всеми JSON-файлами авторизации CLI Proxy (например, Qwen, Gemini, Vertex). Загрузка учётных данных сразу включает соответствующую интеграцию AI.", + "upload_button": "Загрузить файл", + "delete_all_button": "Удалить всё", + "empty_title": "Файлы авторизации отсутствуют", + "empty_desc": "Нажмите кнопку выше, чтобы загрузить первый файл", + "search_empty_title": "Файлы не найдены", + "search_empty_desc": "Попробуйте изменить фильтры или очистить строку поиска.", + "file_size": "Размер", + "file_modified": "Изменён", + "download_button": "Скачать", + "delete_button": "Удалить", + "delete_confirm": "Удалить файл", + "delete_all_confirm": "Удалить все файлы авторизации? Это действие нельзя отменить!", + "delete_filtered_confirm": "Удалить все файлы авторизации {{type}}? Это действие нельзя отменить!", + "upload_error_json": "Допустимы только файлы JSON", + "upload_error_size": "Размер файла не может превышать {{maxSize}}", + "upload_success": "Файл успешно загружен", + "download_success": "Файл успешно скачан", + "delete_success": "Файл успешно удалён", + "delete_all_success": "Удаление завершено", + "delete_filtered_success": "Удалено файлов {{type}}: {{count}}", + "delete_filtered_partial": "Удаление файлов {{type}} завершено: успешных {{success}}, ошибок {{failed}}", + "delete_filtered_none": "Нет файлов {{type}} для удаления при текущем фильтре", + "files_count": "файлов", + "pagination_prev": "Предыдущая", + "pagination_next": "Следующая", + "pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов", + "search_label": "Поиск конфигов", + "search_placeholder": "Фильтр по имени, типу или провайдеру", + "page_size_label": "На странице", + "page_size_unit": "элементов", + "view_mode_paged": "Постранично", + "view_mode_all": "Показать все", + "too_many_files_warning": "Слишком много учётных данных. Полный список может повлиять на производительность, используйте постраничный режим.", + "filter_all": "Все", "filter_qwen": "Qwen", "filter_gemini": "Gemini", "filter_gemini-cli": "GeminiCLI", @@ -382,8 +382,8 @@ "filter_antigravity": "Antigravity", "filter_iflow": "iFlow", "filter_vertex": "Vertex", - "filter_empty": "Empty", - "filter_unknown": "Other", + "filter_empty": "Пусто", + "filter_unknown": "Другое", "type_qwen": "Qwen", "type_gemini": "Gemini", "type_gemini-cli": "GeminiCLI", @@ -393,721 +393,722 @@ "type_antigravity": "Antigravity", "type_iflow": "iFlow", "type_vertex": "Vertex", - "type_empty": "Empty", - "type_unknown": "Other", - "type_virtual": "Virtual auth file", - "models_button": "Models", - "models_title": "Supported models", - "models_loading": "Loading model list...", - "models_empty": "No available models for this credential", - "models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.", - "models_unsupported": "This feature is not supported in the current version", - "models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again", - "models_excluded_badge": "Excluded", - "models_excluded_hint": "This model is excluded by OAuth", - "status_toggle_label": "Enabled", - "status_enabled_success": "\"{{name}}\" enabled", - "status_disabled_success": "\"{{name}}\" disabled", - "prefix_proxy_button": "Edit prefix/proxy_url", - "prefix_proxy_loading": "Loading credential...", - "prefix_proxy_source_label": "Credential JSON", + "type_empty": "Пусто", + "type_unknown": "Другое", + "type_virtual": "Виртуальный файл авторизации", + "models_button": "Модели", + "models_title": "Поддерживаемые модели", + "models_loading": "Загрузка списка моделей...", + "models_empty": "Для этих учётных данных нет доступных моделей", + "models_empty_desc": "Возможно, учётные данные ещё не загружены сервером или к ним не привязаны модели.", + "models_unsupported": "Функция не поддерживается в текущей версии", + "models_unsupported_desc": "Обновите CLI Proxy API до последней версии и повторите попытку", + "models_excluded_badge": "Исключена", + "models_excluded_hint": "Эта модель исключена OAuth", + "status_toggle_label": "Включено", + "status_enabled_success": "\"{{name}}\" включён", + "status_disabled_success": "\"{{name}}\" отключён", + "prefix_proxy_button": "Изменить prefix/proxy_url", + "prefix_proxy_loading": "Загрузка учётных данных...", + "prefix_proxy_source_label": "JSON учётных данных", "prefix_label": "prefix", "proxy_url_label": "proxy_url", "prefix_placeholder": "", "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", - "prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.", - "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully", - "card_tools_title": "Tools", - "quota_refresh_single": "Refresh quota", - "quota_refresh_hint": "Refresh quota for this credential only", - "quota_refresh_success": "Quota refreshed for \"{{name}}\"", - "quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}" + "prefix_proxy_invalid_json": "Эти учётные данные не являются JSON-объектом и не могут быть изменены.", + "prefix_proxy_saved_success": "\"{{name}}\" успешно обновлён", + "card_tools_title": "Инструменты", + "quota_refresh_single": "Обновить квоту", + "quota_refresh_hint": "Обновить квоту только для этих учётных данных", + "quota_refresh_success": "Квота для \"{{name}}\" обновлена", + "quota_refresh_failed": "Не удалось обновить квоту для \"{{name}}\": {{message}}" }, "antigravity_quota": { - "title": "Antigravity Quota", - "empty_title": "No Antigravity Auth Files", - "empty_desc": "Upload an Antigravity credential to view remaining quota.", - "idle": "Not loaded. Click Refresh Button.", - "loading": "Loading quota...", - "load_failed": "Failed to load quota: {{message}}", - "missing_auth_index": "Auth file missing auth_index", - "empty_models": "No quota data available", - "refresh_button": "Refresh Quota", - "fetch_all": "Fetch All" + "title": "Квота Antigravity", + "empty_title": "Файлы авторизации Antigravity отсутствуют", + "empty_desc": "Загрузите учётные данные Antigravity, чтобы увидеть оставшуюся квоту.", + "idle": "Не загружено. Нажмите \"Обновить квоту\".", + "loading": "Загрузка квоты...", + "load_failed": "Не удалось загрузить квоту: {{message}}", + "missing_auth_index": "В файле авторизации отсутствует auth_index", + "empty_models": "Данные по квоте отсутствуют", + "refresh_button": "Обновить квоту", + "fetch_all": "Получить все" }, "codex_quota": { - "title": "Codex Quota", - "empty_title": "No Codex Auth Files", - "empty_desc": "Upload a Codex credential to view quota.", - "idle": "Not loaded. Click Refresh Button.", - "loading": "Loading quota...", - "load_failed": "Failed to load quota: {{message}}", - "missing_auth_index": "Auth file missing auth_index", - "missing_account_id": "Codex credential missing ChatGPT account ID", - "empty_windows": "No quota data available", - "no_access": "This credential has no Codex access (plan: free).", - "refresh_button": "Refresh Quota", - "fetch_all": "Fetch All", - "primary_window": "5-hour limit", - "secondary_window": "Weekly limit", - "code_review_primary_window": "Code review 5-hour limit", - "code_review_secondary_window": "Code review weekly limit", - "plan_label": "Plan", + "title": "Квота Codex", + "empty_title": "Файлы авторизации Codex отсутствуют", + "empty_desc": "Загрузите учётные данные Codex, чтобы увидеть квоту.", + "idle": "Не загружено. Нажмите \"Обновить квоту\".", + "loading": "Загрузка квоты...", + "load_failed": "Не удалось загрузить квоту: {{message}}", + "missing_auth_index": "В файле авторизации отсутствует auth_index", + "missing_account_id": "В учётных данных Codex отсутствует идентификатор аккаунта ChatGPT", + "empty_windows": "Данные по квоте отсутствуют", + "no_access": "У этих учётных данных нет доступа Codex (план: free).", + "refresh_button": "Обновить квоту", + "fetch_all": "Получить все", + "primary_window": "Лимит на 5 часов", + "secondary_window": "Недельный лимит", + "code_review_primary_window": "Лимит code review на 5 часов", + "code_review_secondary_window": "Недельный лимит code review", + "plan_label": "Тариф", "plan_plus": "Plus", "plan_team": "Team", "plan_free": "Free" }, "gemini_cli_quota": { - "title": "Gemini CLI Quota", - "empty_title": "No Gemini CLI Auth Files", - "empty_desc": "Upload a Gemini CLI credential to view remaining quota.", - "idle": "Not loaded. Click Refresh Button.", - "loading": "Loading quota...", - "load_failed": "Failed to load quota: {{message}}", - "missing_auth_index": "Auth file missing auth_index", - "missing_project_id": "Gemini CLI credential missing project ID", - "empty_buckets": "No quota data available", - "refresh_button": "Refresh Quota", - "fetch_all": "Fetch All", - "remaining_amount": "Remaining {{count}}" + "title": "Квота Gemini CLI", + "empty_title": "Файлы авторизации Gemini CLI отсутствуют", + "empty_desc": "Загрузите учётные данные Gemini CLI, чтобы увидеть оставшуюся квоту.", + "idle": "Не загружено. Нажмите \"Обновить квоту\".", + "loading": "Загрузка квоты...", + "load_failed": "Не удалось загрузить квоту: {{message}}", + "missing_auth_index": "В файле авторизации отсутствует auth_index", + "missing_project_id": "В учётных данных Gemini CLI отсутствует идентификатор проекта", + "empty_buckets": "Данные по квоте отсутствуют", + "refresh_button": "Обновить квоту", + "fetch_all": "Получить все", + "remaining_amount": "Осталось {{count}}" }, "vertex_import": { - "title": "Vertex JSON Login", - "description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.", - "location_label": "Region (optional)", + "title": "Вход с Vertex JSON", + "description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-.json по тем же правилам, что и помощник CLI vertex-import.", + "location_label": "Регион (необязательно)", "location_placeholder": "us-central1", - "location_hint": "Leave empty to use the default region us-central1.", - "file_label": "Service account key JSON", - "file_hint": "Only Google Cloud service account key JSON files are accepted.", - "file_placeholder": "No file selected", - "choose_file": "Choose File", - "import_button": "Import Vertex Credential", - "file_required": "Select a .json credential file first", - "success": "Vertex credential imported successfully", - "result_title": "Credential saved", - "result_project": "Project ID", - "result_email": "Service account", - "result_location": "Region", - "result_file": "Persisted file" + "location_hint": "Оставьте пустым, чтобы использовать регион us-central1 по умолчанию.", + "file_label": "JSON ключ сервисного аккаунта", + "file_hint": "Принимаются только JSON-файлы ключей сервисных аккаунтов Google Cloud.", + "file_placeholder": "Файл не выбран", + "choose_file": "Выбрать файл", + "import_button": "Импортировать учётные данные Vertex", + "file_required": "Сначала выберите файл учётных данных .json", + "success": "Учётные данные Vertex успешно импортированы", + "result_title": "Учётные данные сохранены", + "result_project": "ID проекта", + "result_email": "Сервисный аккаунт", + "result_location": "Регион", + "result_file": "Сохранённый файл" }, "oauth_excluded": { - "title": "OAuth Excluded Models", - "description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.", - "add": "Add Exclusion", - "add_title": "Add provider exclusion", - "edit_title": "Edit exclusions for {{provider}}", - "refresh": "Refresh", - "refreshing": "Refreshing...", - "provider_label": "Provider", - "provider_auto": "Follow current filter", - "provider_placeholder": "e.g. gemini-cli", - "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", - "models_label": "Models to exclude", - "models_loading": "Loading models...", - "models_unsupported": "Current CPA version does not support fetching model lists.", - "models_loaded": "{{count}} models loaded. Check the models to exclude.", - "no_models_available": "No models available for this provider.", - "save": "Save/Update", - "saving": "Saving...", - "save_success": "Excluded models updated", - "save_failed": "Failed to update excluded models", - "delete": "Delete Provider", - "delete_confirm": "Delete the exclusion list for {{provider}}?", - "delete_success": "Exclusion list removed", - "delete_failed": "Failed to delete exclusion list", - "deleting": "Deleting...", - "no_models": "No excluded models", - "model_count": "{{count}} models excluded", - "list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.", - "list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.", - "disconnected": "Connect to the server to view exclusions", - "load_failed": "Failed to load exclusion list", - "provider_required": "Please enter a provider first", - "scope_all": "Scope: All providers", - "scope_provider": "Scope: {{provider}}", - "upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", - "upgrade_required_title": "Please upgrade CLI Proxy API", - "upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." + "title": "Исключённые модели OAuth", + "description": "Исключения по провайдерам отображаются карточками; нажмите \"Изменить\", чтобы настроить. Поддерживаются шаблоны *. Объём зависит от фильтра файлов авторизации.", + "add": "Добавить исключение", + "add_title": "Добавление исключения для провайдера", + "edit_title": "Редактирование исключений для {{provider}}", + "refresh": "Обновить", + "refreshing": "Обновляется...", + "provider_label": "Провайдер", + "provider_auto": "Следовать текущему фильтру", + "provider_placeholder": "например: gemini-cli", + "provider_hint": "По умолчанию используется текущий фильтр; выберите существующего провайдера или введите новое имя.", + "models_label": "Модели для исключения", + "models_loading": "Загрузка моделей...", + "models_unsupported": "Текущая версия CPA не поддерживает загрузку списка моделей.", + "models_loaded": "Загружено моделей: {{count}}. Отметьте те, которые нужно исключить.", + "no_models_available": "Для этого провайдера нет доступных моделей.", + "save": "Сохранить/обновить", + "saving": "Сохранение...", + "save_success": "Исключения обновлены", + "save_failed": "Не удалось обновить исключения", + "delete": "Удалить провайдера", + "delete_confirm": "Удалить список исключений для {{provider}}?", + "delete_success": "Список исключений удалён", + "delete_failed": "Не удалось удалить список исключений", + "deleting": "Удаление...", + "no_models": "Исключённых моделей нет", + "model_count": "Исключено моделей: {{count}}", + "list_empty_all": "Исключений ещё нет — используйте \"Добавить исключение\".", + "list_empty_filtered": "В этом диапазоне исключений нет; нажмите \"Добавить исключение\".", + "disconnected": "Подключитесь к серверу, чтобы просматривать исключения", + "load_failed": "Не удалось загрузить список исключений", + "provider_required": "Сначала укажите провайдера", + "scope_all": "Область: все провайдеры", + "scope_provider": "Область: {{provider}}", + "upgrade_required": "Эта функция требует более новой версии CLI Proxy API (CPA). Обновите систему.", + "upgrade_required_title": "Пожалуйста, обновите CLI Proxy API", + "upgrade_required_desc": "Текущая версия сервера не поддерживает API исключённых моделей OAuth. Обновите CLI Proxy API (CPA) до последней версии." }, "oauth_model_alias": { - "title": "OAuth Model Aliases", - "add": "Add Alias", - "add_title": "Add provider model aliases", - "provider_label": "Provider", - "provider_placeholder": "e.g. gemini-cli / vertex", - "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", - "model_source_loading": "Loading models...", - "model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).", - "model_source_loaded": "{{count}} models loaded. Use the dropdown in 'Source model name', or type custom values. Saving an empty list removes that provider. Enable 'Keep original' to keep the original name while adding the alias.", - "alias_label": "Model aliases", - "alias_name_placeholder": "Source model name", - "alias_placeholder": "Alias (required)", - "alias_fork_label": "Keep original", - "add_alias": "Add alias", - "save": "Save/Update", - "save_success": "Model aliases updated", - "save_failed": "Failed to update model aliases", - "delete": "Delete Provider", - "delete_confirm": "Delete model aliases for {{provider}}?", - "delete_link_title": "Unlink mapping", - "delete_link_confirm": "Unlink mapping from {{sourceModel}} ({{provider}}) to alias {{alias}}?", - "delete_alias_title": "Delete Alias", - "delete_alias_confirm": "Delete alias {{alias}} and unmap all associated models?", - "delete_success": "Model aliases removed", - "delete_failed": "Failed to delete model aliases", - "no_models": "No model aliases", - "model_count": "{{count}} aliases", - "list_empty_all": "No model aliases yet—use “Add Alias” to create one.", - "chart_title": "All mappings overview", - "diagram_providers": "Providers", - "diagram_source_models": "Source Models", - "diagram_aliases": "Aliases", - "diagram_expand": "Expand", - "diagram_collapse": "Collapse", - "diagram_add_alias": "Add Alias", - "diagram_rename": "Rename", - "diagram_rename_alias_title": "Rename alias", - "diagram_rename_alias_label": "New alias name", - "diagram_rename_placeholder": "Enter alias name...", - "diagram_delete_link": "Unlink from {{provider}} / {{name}}", - "diagram_delete_alias": "Delete alias", - "diagram_please_enter_alias": "Please enter an alias name.", - "diagram_alias_exists": "This alias already exists.", - "diagram_add_alias_title": "Add alias", - "diagram_add_alias_label": "Alias name", - "diagram_add_placeholder": "Enter new alias name...", - "diagram_rename_btn": "Rename", - "diagram_add_btn": "Add", - "diagram_settings": "Settings", - "diagram_settings_title": "Alias settings — {{alias}}", - "diagram_settings_source_title": "Source model settings", - "diagram_settings_empty": "No mappings for this alias yet.", - "diagram_tap_hint": "On touch devices: tap a source model, then tap an alias to link.", - "view_mode": "View mode", - "view_mode_diagram": "Diagram", - "view_mode_list": "List", - "provider_required": "Please enter a provider first", - "upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", - "upgrade_required_title": "Please upgrade CLI Proxy API", - "upgrade_required_desc": "The current server does not support the OAuth model aliases API. Please upgrade to the latest CLI Proxy API (CPA) version." + "title": "Псевдонимы моделей OAuth", + "add": "Добавить псевдоним", + "add_title": "Добавление псевдонимов моделей провайдера", + "provider_label": "Провайдер", + "provider_placeholder": "например: gemini-cli / vertex", + "provider_hint": "По умолчанию используется текущий фильтр; выберите существующего провайдера или введите новое имя.", + "model_source_loading": "Загрузка моделей...", + "model_source_unsupported": "Текущая версия CPA не поддерживает загрузку списка моделей (ручной ввод остаётся доступным).", + "model_source_loaded": "Загружено моделей: {{count}}. Используйте выпадающий список \"Исходная модель\" или введите своё значение. Сохранение пустого списка удаляет провайдера. Включите \"Сохранить оригинал\", чтобы оставить исходное имя вместе с псевдонимом.", + "alias_label": "Псевдонимы моделей", + "alias_name_placeholder": "Исходное имя модели", + "alias_placeholder": "Псевдоним (обязательно)", + "alias_fork_label": "Сохранить оригинал", + "add_alias": "Добавить псевдоним", + "save": "Сохранить/обновить", + "save_success": "Псевдонимы моделей обновлены", + "save_failed": "Не удалось обновить псевдонимы моделей", + "delete": "Удалить провайдера", + "delete_confirm": "Удалить псевдонимы моделей для {{provider}}?", + "delete_link_title": "Убрать сопоставление", + "delete_link_confirm": "Удалить сопоставление из {{sourceModel}} ({{provider}}) к псевдониму {{alias}}?", + "delete_alias_title": "Удалить псевдоним", + "delete_alias_confirm": "Удалить псевдоним {{alias}} и все связанные сопоставления?", + "delete_success": "Псевдонимы моделей удалены", + "delete_failed": "Не удалось удалить псевдонимы моделей", + "no_models": "Псевдонимов нет", + "model_count": "Количество псевдонимов: {{count}}", + "list_empty_all": "Псевдонимы ещё не созданы — используйте \"Добавить псевдоним\".", + "chart_title": "Обзор всех сопоставлений", + "diagram_providers": "Провайдеры", + "diagram_source_models": "Исходные модели", + "diagram_aliases": "Псевдонимы", + "diagram_expand": "Развернуть", + "diagram_collapse": "Свернуть", + "diagram_add_alias": "Добавить псевдоним", + "diagram_rename": "Переименовать", + "diagram_rename_alias_title": "Переименование псевдонима", + "diagram_rename_alias_label": "Новое имя псевдонима", + "diagram_rename_placeholder": "Введите имя псевдонима...", + "diagram_delete_link": "Убрать связь {{provider}} / {{name}}", + "diagram_delete_alias": "Удалить псевдоним", + "diagram_please_enter_alias": "Введите имя псевдонима.", + "diagram_alias_exists": "Этот псевдоним уже существует.", + "diagram_add_alias_title": "Добавление псевдонима", + "diagram_add_alias_label": "Имя псевдонима", + "diagram_add_placeholder": "Введите новое имя псевдонима...", + "diagram_rename_btn": "Переименовать", + "diagram_add_btn": "Добавить", + "diagram_settings": "Настройки", + "diagram_settings_title": "Настройки псевдонима — {{alias}}", + "diagram_settings_source_title": "Настройки исходной модели", + "diagram_settings_empty": "Для этого псевдонима ещё нет сопоставлений.", + "diagram_tap_hint": "На сенсорных устройствах: коснитесь исходной модели, затем псевдонима для связывания.", + "view_mode": "Режим просмотра", + "view_mode_diagram": "Диаграмма", + "view_mode_list": "Список", + "provider_required": "Сначала укажите провайдера", + "upgrade_required": "Эта функция требует более новой версии CLI Proxy API (CPA). Обновите систему.", + "upgrade_required_title": "Пожалуйста, обновите CLI Proxy API", + "upgrade_required_desc": "Текущая версия сервера не поддерживает API псевдонимов моделей OAuth. Обновите CLI Proxy API (CPA) до последней версии." }, "auth_login": { "codex_oauth_title": "Codex OAuth", - "codex_oauth_button": "Start Codex Login", - "codex_oauth_hint": "Login to Codex service through OAuth flow, automatically obtain and save authentication files.", - "codex_oauth_url_label": "Authorization URL:", - "codex_open_link": "Open Link", - "codex_copy_link": "Copy Link", - "codex_oauth_status_waiting": "Waiting for authentication...", - "codex_oauth_status_success": "Authentication successful!", - "codex_oauth_status_error": "Authentication failed:", - "codex_oauth_start_error": "Failed to start Codex OAuth:", - "codex_oauth_polling_error": "Failed to check authentication status:", + "codex_oauth_button": "Начать вход Codex", + "codex_oauth_hint": "Выполните вход в сервис Codex через OAuth и автоматически получите/сохраните файлы авторизации.", + "codex_oauth_url_label": "URL авторизации:", + "codex_open_link": "Открыть ссылку", + "codex_copy_link": "Скопировать ссылку", + "codex_oauth_status_waiting": "Ожидание аутентификации...", + "codex_oauth_status_success": "Аутентификация успешна!", + "codex_oauth_status_error": "Ошибка аутентификации:", + "codex_oauth_start_error": "Не удалось запустить Codex OAuth:", + "codex_oauth_polling_error": "Не удалось проверить статус аутентификации:", "anthropic_oauth_title": "Anthropic OAuth", - "anthropic_oauth_button": "Start Anthropic Login", - "anthropic_oauth_hint": "Login to Anthropic (Claude) service through OAuth flow, automatically obtain and save authentication files.", - "anthropic_oauth_url_label": "Authorization URL:", - "anthropic_open_link": "Open Link", - "anthropic_copy_link": "Copy Link", - "anthropic_oauth_status_waiting": "Waiting for authentication...", - "anthropic_oauth_status_success": "Authentication successful!", - "anthropic_oauth_status_error": "Authentication failed:", - "anthropic_oauth_start_error": "Failed to start Anthropogenic OAuth:", - "anthropic_oauth_polling_error": "Failed to check authentication status:", + "anthropic_oauth_button": "Начать вход Anthropic", + "anthropic_oauth_hint": "Выполните вход в сервис Anthropic (Claude) через OAuth и автоматически получите/сохраните файлы авторизации.", + "anthropic_oauth_url_label": "URL авторизации:", + "anthropic_open_link": "Открыть ссылку", + "anthropic_copy_link": "Скопировать ссылку", + "anthropic_oauth_status_waiting": "Ожидание аутентификации...", + "anthropic_oauth_status_success": "Аутентификация успешна!", + "anthropic_oauth_status_error": "Ошибка аутентификации:", + "anthropic_oauth_start_error": "Не удалось запустить Anthropic OAuth:", + "anthropic_oauth_polling_error": "Не удалось проверить статус аутентификации:", "antigravity_oauth_title": "Antigravity OAuth", - "antigravity_oauth_button": "Start Antigravity Login", - "antigravity_oauth_hint": "Login to Antigravity service (Google account) through OAuth flow, automatically obtain and save authentication files.", - "antigravity_oauth_url_label": "Authorization URL:", - "antigravity_open_link": "Open Link", - "antigravity_copy_link": "Copy Link", - "antigravity_oauth_status_waiting": "Waiting for authentication...", - "antigravity_oauth_status_success": "Authentication successful!", - "antigravity_oauth_status_error": "Authentication failed:", - "antigravity_oauth_start_error": "Failed to start Antigravity OAuth:", - "antigravity_oauth_polling_error": "Failed to check authentication status:", + "antigravity_oauth_button": "Начать вход Antigravity", + "antigravity_oauth_hint": "Выполните вход в сервис Antigravity (Google) через OAuth и автоматически получите/сохраните файлы авторизации.", + "antigravity_oauth_url_label": "URL авторизации:", + "antigravity_open_link": "Открыть ссылку", + "antigravity_copy_link": "Скопировать ссылку", + "antigravity_oauth_status_waiting": "Ожидание аутентификации...", + "antigravity_oauth_status_success": "Аутентификация успешна!", + "antigravity_oauth_status_error": "Ошибка аутентификации:", + "antigravity_oauth_start_error": "Не удалось запустить Antigravity OAuth:", + "antigravity_oauth_polling_error": "Не удалось проверить статус аутентификации:", "gemini_cli_oauth_title": "Gemini CLI OAuth", - "gemini_cli_oauth_button": "Start Gemini CLI Login", - "gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.", - "gemini_cli_project_id_label": "Google Cloud Project ID (Optional):", - "gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project", - "gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.", - "gemini_cli_project_id_required": "Please enter a Google Cloud project ID.", - "gemini_cli_oauth_url_label": "Authorization URL:", - "gemini_cli_open_link": "Open Link", - "gemini_cli_copy_link": "Copy Link", - "gemini_cli_oauth_status_waiting": "Waiting for authentication...", - "gemini_cli_oauth_status_success": "Authentication successful!", - "gemini_cli_oauth_status_error": "Authentication failed:", - "gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:", - "gemini_cli_oauth_polling_error": "Failed to check authentication status:", + "gemini_cli_oauth_button": "Начать вход Gemini CLI", + "gemini_cli_oauth_hint": "Выполните вход в сервис Google Gemini CLI через OAuth и автоматически получите/сохраните файлы авторизации.", + "gemini_cli_project_id_label": "Google Cloud Project ID (необязательно):", + "gemini_cli_project_id_placeholder": "Оставьте пустым, чтобы выбрать первый доступный проект автоматически", + "gemini_cli_project_id_hint": "Необязательно. Если не указано, система автоматически выберет первый доступный проект вашей учётной записи.", + "gemini_cli_project_id_required": "Укажите идентификатор проекта Google Cloud.", + "gemini_cli_oauth_url_label": "URL авторизации:", + "gemini_cli_open_link": "Открыть ссылку", + "gemini_cli_copy_link": "Скопировать ссылку", + "gemini_cli_oauth_status_waiting": "Ожидание аутентификации...", + "gemini_cli_oauth_status_success": "Аутентификация успешна!", + "gemini_cli_oauth_status_error": "Ошибка аутентификации:", + "gemini_cli_oauth_start_error": "Не удалось запустить Gemini CLI OAuth:", + "gemini_cli_oauth_polling_error": "Не удалось проверить статус аутентификации:", "qwen_oauth_title": "Qwen OAuth", - "qwen_oauth_button": "Start Qwen Login", - "qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.", - "qwen_oauth_url_label": "Authorization URL:", - "qwen_open_link": "Open Link", - "qwen_copy_link": "Copy Link", - "qwen_oauth_status_waiting": "Waiting for authentication...", - "qwen_oauth_status_success": "Authentication successful!", - "qwen_oauth_status_error": "Authentication failed:", - "qwen_oauth_start_error": "Failed to start Qwen OAuth:", - "qwen_oauth_polling_error": "Failed to check authentication status:", + "qwen_oauth_button": "Начать вход Qwen", + "qwen_oauth_hint": "Выполните вход в сервис Qwen через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.", + "qwen_oauth_url_label": "URL авторизации:", + "qwen_open_link": "Открыть ссылку", + "qwen_copy_link": "Скопировать ссылку", + "qwen_oauth_status_waiting": "Ожидание аутентификации...", + "qwen_oauth_status_success": "Аутентификация успешна!", + "qwen_oauth_status_error": "Ошибка аутентификации:", + "qwen_oauth_start_error": "Не удалось запустить Qwen OAuth:", + "qwen_oauth_polling_error": "Не удалось проверить статус аутентификации:", "oauth_callback_label": "Callback URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", - "oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.", - "oauth_callback_button": "Submit Callback URL", - "oauth_callback_required": "Please paste the full redirect URL first.", - "oauth_callback_success": "Callback URL submitted. Continue waiting for authentication.", - "oauth_callback_error": "Failed to submit callback URL:", - "oauth_callback_upgrade_hint": "Please update CLI Proxy API or check the connection.", - "oauth_callback_status_success": "Callback URL submitted, waiting for authentication...", - "oauth_callback_status_error": "Callback URL submission failed:", - "missing_state": "Unable to retrieve authentication state parameter", + "oauth_callback_hint": "Режим удалённого браузера: после перенаправления провайдера на http://localhost:... скопируйте полный URL и отправьте его здесь.", + "oauth_callback_button": "Отправить Callback URL", + "oauth_callback_required": "Сначала вставьте полный URL перенаправления.", + "oauth_callback_success": "Callback URL отправлен. Продолжайте ожидать аутентификацию.", + "oauth_callback_error": "Не удалось отправить Callback URL:", + "oauth_callback_upgrade_hint": "Обновите CLI Proxy API или проверьте подключение.", + "oauth_callback_status_success": "Callback URL отправлен, ожидаем аутентификацию...", + "oauth_callback_status_error": "Не удалось отправить Callback URL:", + "missing_state": "Не удалось получить параметр состояния аутентификации", "iflow_oauth_title": "iFlow OAuth", - "iflow_oauth_button": "Start iFlow Login", - "iflow_oauth_hint": "Login to iFlow service through OAuth flow, automatically obtain and save authentication files.", - "iflow_oauth_url_label": "Authorization URL:", - "iflow_open_link": "Open Link", - "iflow_copy_link": "Copy Link", - "iflow_oauth_status_waiting": "Waiting for authentication...", - "iflow_oauth_status_success": "Authentication successful!", - "iflow_oauth_status_error": "Authentication failed:", - "iflow_oauth_start_error": "Failed to start iFlow OAuth:", - "iflow_oauth_polling_error": "Failed to check authentication status:", - "iflow_cookie_title": "iFlow Cookie Login", - "iflow_cookie_label": "Cookie Value:", - "iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=", - "iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.", - "iflow_cookie_key_hint": "Note: Create a key on the platform first.", - "iflow_cookie_button": "Submit Cookie Login", - "iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.", - "iflow_cookie_status_error": "Cookie login failed:", - "iflow_cookie_status_duplicate": "Duplicate config:", - "iflow_cookie_start_error": "Failed to submit cookie login:", - "iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.", - "iflow_cookie_required": "Please provide the Cookie value first.", - "iflow_cookie_result_title": "Cookie Login Result", - "iflow_cookie_result_email": "Account", - "iflow_cookie_result_expired": "Expires At", - "iflow_cookie_result_path": "Saved Path", - "iflow_cookie_result_type": "Type", - "remote_access_disabled": "This login method is not available for remote access. Please access from localhost." + "iflow_oauth_button": "Начать вход iFlow", + "iflow_oauth_hint": "Выполните вход в сервис iFlow через OAuth и автоматически получите/сохраните файлы авторизации.", + "iflow_oauth_url_label": "URL авторизации:", + "iflow_open_link": "Открыть ссылку", + "iflow_copy_link": "Скопировать ссылку", + "iflow_oauth_status_waiting": "Ожидание аутентификации...", + "iflow_oauth_status_success": "Аутентификация успешна!", + "iflow_oauth_status_error": "Ошибка аутентификации:", + "iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:", + "iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:", + "iflow_cookie_title": "Вход iFlow по cookie", + "iflow_cookie_label": "Значение cookie:", + "iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=", + "iflow_cookie_hint": "Отправьте существующий cookie, чтобы завершить вход без открытия ссылки авторизации; файл учётных данных будет сохранён автоматически.", + "iflow_cookie_key_hint": "Примечание: сначала создайте ключ на платформе.", + "iflow_cookie_button": "Отправить вход по cookie", + "iflow_cookie_status_success": "Вход по cookie выполнен, учётные данные сохранены.", + "iflow_cookie_status_error": "Ошибка входа по cookie:", + "iflow_cookie_status_duplicate": "Дублирующая конфигурация:", + "iflow_cookie_start_error": "Не удалось отправить вход по cookie:", + "iflow_cookie_config_duplicate": "Такая конфигурация уже существует. Удалите файл и повторите, если хотите перезаписать.", + "iflow_cookie_required": "Сначала укажите значение cookie.", + "iflow_cookie_result_title": "Результат входа по cookie", + "iflow_cookie_result_email": "Аккаунт", + "iflow_cookie_result_expired": "Истекает", + "iflow_cookie_result_path": "Путь сохранения", + "iflow_cookie_result_type": "Тип", + "remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost." }, "usage_stats": { - "title": "Usage Statistics", - "total_requests": "Total Requests", - "success_requests": "Success Requests", - "failed_requests": "Failed Requests", - "total_tokens": "Total Tokens", - "cached_tokens": "Cached Tokens", - "reasoning_tokens": "Reasoning Tokens", + "title": "Статистика использования", + "total_requests": "Всего запросов", + "success_requests": "Успешные запросы", + "failed_requests": "Неуспешные запросы", + "total_tokens": "Всего токенов", + "cached_tokens": "Кэшированные токены", + "reasoning_tokens": "Токены рассуждений", "rpm_30m": "RPM", "tpm_30m": "TPM", - "rate_30m": "Rate (last 30 min)", - "model_name": "Model Name", - "model_price_settings": "Model Pricing Settings", - "saved_prices": "Saved Prices", - "requests_trend": "Request Trends", - "tokens_trend": "Token Usage Trends", - "api_details": "API Details", - "by_hour": "By Hour", - "by_day": "By Day", - "refresh": "Refresh", - "export": "Export", - "import": "Import", - "export_success": "Usage export downloaded", - "import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}", - "import_invalid": "Invalid usage export file", - "chart_line_label_1": "Line 1", - "chart_line_label_2": "Line 2", - "chart_line_label_3": "Line 3", - "chart_line_label_4": "Line 4", - "chart_line_label_5": "Line 5", - "chart_line_label_6": "Line 6", - "chart_line_label_7": "Line 7", - "chart_line_label_8": "Line 8", - "chart_line_label_9": "Line 9", - "chart_line_hidden": "Hide", - "chart_line_actions_label": "Lines to display", - "chart_line_add": "Add line", - "chart_line_all": "All", - "chart_line_delete": "Delete line", - "chart_line_hint": "Show up to 9 model lines at once", - "no_data": "No Data Available", - "loading_error": "Loading Failed", - "api_endpoint": "API Endpoint", - "requests_count": "Request Count", - "tokens_count": "Token Count", - "models": "Model Statistics", - "success_rate": "Success Rate", - "total_cost": "Total Cost", - "total_cost_hint": "Based on configured model pricing", - "model_price_title": "Model Pricing", - "model_price_reset": "Clear Prices", - "model_price_model_label": "Model", - "model_price_select_placeholder": "Choose a model", - "model_price_select_hint": "Models come from usage details", - "model_price_prompt": "Prompt price", - "model_price_completion": "Completion price", - "model_price_cache": "Cache price", - "model_price_save": "Save Price", - "model_price_empty": "No model prices set", - "model_price_model": "Model", - "model_price_saved": "Model price saved", - "model_price_model_required": "Please choose a model to set pricing", - "cost_trend": "Cost Overview", - "cost_axis_label": "Cost ($)", - "cost_need_price": "Set a model price to view cost stats", - "cost_need_usage": "No usage data available to calculate cost", - "cost_no_data": "No cost data yet" + "rate_30m": "Скорость (последние 30 мин)", + "model_name": "Название модели", + "model_price_settings": "Настройки стоимости моделей", + "saved_prices": "Сохранённые цены", + "requests_trend": "Динамика запросов", + "tokens_trend": "Динамика токенов", + "api_details": "Детали API", + "by_hour": "По часам", + "by_day": "По дням", + "refresh": "Обновить", + "export": "Экспорт", + "import": "Импорт", + "export_success": "Экспорт использования скачан", + "import_success": "Импорт завершён: добавлено {{added}}, пропущено {{skipped}}, всего {{total}}, ошибок {{failed}}", + "import_invalid": "Недопустимый файл экспорта использования", + "chart_line_label_1": "Линия 1", + "chart_line_label_2": "Линия 2", + "chart_line_label_3": "Линия 3", + "chart_line_label_4": "Линия 4", + "chart_line_label_5": "Линия 5", + "chart_line_label_6": "Линия 6", + "chart_line_label_7": "Линия 7", + "chart_line_label_8": "Линия 8", + "chart_line_label_9": "Линия 9", + "chart_line_hidden": "Скрыть", + "chart_line_actions_label": "Линии для отображения", + "chart_line_add": "Добавить линию", + "chart_line_all": "Все", + "chart_line_delete": "Удалить линию", + "chart_line_hint": "Отображайте до 9 линий моделей одновременно", + "no_data": "Данные отсутствуют", + "loading_error": "Не удалось загрузить", + "api_endpoint": "API-эндпоинт", + "requests_count": "Количество запросов", + "tokens_count": "Количество токенов", + "models": "Статистика моделей", + "success_rate": "Успешность", + "total_cost": "Общая стоимость", + "total_cost_hint": "Основано на настроенной стоимости моделей", + "model_price_title": "Стоимость моделей", + "model_price_reset": "Сбросить цены", + "model_price_model_label": "Модель", + "model_price_select_placeholder": "Выберите модель", + "model_price_select_hint": "Модели берутся из детальной статистики использования", + "model_price_prompt": "Цена prompt", + "model_price_completion": "Цена completion", + "model_price_cache": "Цена кэша", + "model_price_save": "Сохранить цену", + "model_price_empty": "Цена для моделей не задана", + "model_price_model": "Модель", + "model_price_saved": "Цена модели сохранена", + "model_price_model_required": "Выберите модель для задания цены", + "cost_trend": "Обзор стоимости", + "cost_axis_label": "Стоимость ($)", + "cost_need_price": "Задайте стоимость модели, чтобы увидеть статистику затрат", + "cost_need_usage": "Нет данных использования для расчёта стоимости", + "cost_no_data": "Данных о стоимости ещё нет" }, "stats": { - "success": "Success", - "failure": "Failure" + "success": "Успех", + "failure": "Сбой" }, "logs": { - "title": "Logs Viewer", - "refresh_button": "Refresh Logs", - "clear_button": "Clear Logs", - "download_button": "Download Logs", - "error_log_button": "Select Error Log", - "error_logs_modal_title": "Error Request Logs", - "error_logs_description": "Pick an error request log file to download (only generated when request logging is off).", - "error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.", - "error_logs_empty": "No error request log files found", - "error_logs_load_error": "Failed to load error log list", - "error_logs_size": "Size", - "error_logs_modified": "Last modified", - "error_logs_download": "Download", - "error_log_download_success": "Error log downloaded successfully", - "request_log_download_title": "Download Request Log", - "request_log_download_confirm": "Download request log for ID {{id}}?", - "request_log_download_success": "Request log downloaded successfully", - "empty_title": "No Logs Available", - "empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here", - "log_content": "Log Content", - "loading": "Loading logs...", - "load_error": "Failed to load logs", - "clear_confirm": "Are you sure you want to clear all logs? This action cannot be undone!", - "clear_success": "Logs cleared successfully", - "download_success": "Logs downloaded successfully", - "auto_refresh": "Auto Refresh", - "auto_refresh_enabled": "Auto refresh enabled", - "auto_refresh_disabled": "Auto refresh disabled", - "load_more_hint": "Scroll up to load more", - "hidden_lines": "Hidden: {{count}} lines", - "loaded_lines": "Loaded: {{count}} lines", - "filtered_lines": "Filtered: {{count}} lines", - "hide_management_logs": "Hide {{prefix}} logs", - "show_raw_logs": "Show Raw Logs", - "show_raw_logs_hint": "Show original log text for easier multi-line copy", - "search_placeholder": "Search logs by content or keyword", - "search_empty_title": "No matching logs found", - "search_empty_desc": "Try a different keyword or clear the filters.", - "double_click_copy_hint": "Double-click to copy raw log line", - "copy_success": "Log copied to clipboard", - "copy_failed": "Copy failed", - "lines": "lines", - "removed": "Filtered", - "upgrade_required_title": "Please Upgrade CLI Proxy API", - "upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature." + "title": "Просмотр журналов", + "refresh_button": "Обновить журналы", + "clear_button": "Очистить журналы", + "download_button": "Скачать журналы", + "error_log_button": "Выбрать журнал ошибок", + "error_logs_modal_title": "Журналы ошибок запросов", + "error_logs_description": "Выберите файл журнала ошибок запроса для скачивания (создаётся только при отключённом журналировании запросов).", + "error_logs_request_log_enabled": "Журналирование запросов включено, поэтому этот список всегда будет пустым. Отключите журналирование запросов и обновите список, чтобы просмотреть журналы ошибок.", + "error_logs_empty": "Файлы журнала ошибок запросов не найдены", + "error_logs_load_error": "Не удалось загрузить список журналов ошибок", + "error_logs_size": "Размер", + "error_logs_modified": "Изменён", + "error_logs_download": "Скачать", + "error_log_download_success": "Журнал ошибок успешно скачан", + "request_log_download_title": "Скачать журнал запросов", + "request_log_download_confirm": "Скачать журнал запросов с идентификатором {{id}}?", + "request_log_download_success": "Журнал запросов успешно скачан", + "empty_title": "Журналы недоступны", + "empty_desc": "Когда включена опция \"Включить журналирование в файл\", журналы появятся здесь", + "log_content": "Содержимое журнала", + "loading": "Загрузка журналов...", + "load_error": "Не удалось загрузить журналы", + "clear_confirm": "Очистить все журналы? Действие нельзя отменить!", + "clear_success": "Журналы успешно очищены", + "download_success": "Журналы успешно скачаны", + "auto_refresh": "Автообновление", + "auto_refresh_enabled": "Автообновление включено", + "auto_refresh_disabled": "Автообновление выключено", + "load_more_hint": "Прокрутите вверх, чтобы загрузить ещё", + "hidden_lines": "Скрыто: {{count}} строк", + "loaded_lines": "Загружено: {{count}} строк", + "filtered_lines": "Отфильтровано: {{count}} строк", + "hide_management_logs": "Скрыть журналы {{prefix}}", + "show_raw_logs": "Показать исходные журналы", + "show_raw_logs_hint": "Показать текст журнала без обработки для удобного копирования в несколько строк", + "search_placeholder": "Искать по содержимому или ключевым словам", + "search_empty_title": "Подходящих журналов не найдено", + "search_empty_desc": "Попробуйте другой запрос или сбросьте фильтры.", + "double_click_copy_hint": "Дважды нажмите, чтобы скопировать строку журнала", + "copy_success": "Строка журнала скопирована", + "copy_failed": "Не удалось скопировать", + "lines": "строк", + "removed": "Отфильтровано", + "upgrade_required_title": "Обновите CLI Proxy API", + "upgrade_required_desc": "Текущая версия сервера не поддерживает просмотр журналов. Обновите CLI Proxy API до последней версии, чтобы использовать эту функцию." }, "config_management": { - "title": "Config Panel", - "editor_title": "Configuration File", - "reload": "Reload", - "save": "Save", - "description": "Edit config.yaml via visual editor or source file", - "status_idle": "Waiting for action", - "status_loading": "Loading configuration...", - "status_loaded": "Configuration loaded", - "status_dirty": "Unsaved changes", - "status_disconnected": "Connect to the server to load the configuration", - "status_load_failed": "Load failed", - "status_saving": "Saving configuration...", - "status_saved": "Configuration saved", - "status_save_failed": "Save failed", - "save_success": "Configuration saved successfully", - "error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.", + "title": "Панель конфигурации", + "editor_title": "Файл конфигурации", + "reload": "Перезагрузить", + "save": "Сохранить", + "description": "Редактируйте config.yaml через визуальный редактор или исходный файл", + "status_idle": "Ожидание действия", + "status_loading": "Загрузка конфигурации...", + "status_loaded": "Конфигурация загружена", + "status_dirty": "Есть несохранённые изменения", + "status_disconnected": "Подключитесь к серверу, чтобы загрузить конфигурацию", + "status_load_failed": "Не удалось загрузить", + "status_saving": "Сохранение конфигурации...", + "status_saved": "Конфигурация сохранена", + "status_save_failed": "Не удалось сохранить", + "save_success": "Конфигурация успешно сохранена", + "error_yaml_not_supported": "Сервер не вернул YAML. Убедитесь, что доступна конечная точка /config.yaml.", "editor_placeholder": "key: value", - "search_placeholder": "Search config...", - "search_button": "Search", - "search_no_results": "No results", - "search_prev": "Previous", - "search_next": "Next", + "search_placeholder": "Поиск по конфигурации...", + "search_button": "Поиск", + "search_no_results": "Нет результатов", + "search_prev": "Назад", + "search_next": "Вперёд", "tabs": { - "visual": "Visual Editor", - "source": "Source File Editor" + "visual": "Визуальный редактор", + "source": "Редактор файла" }, "visual": { "sections": { "server": { - "title": "Server Configuration", - "description": "Basic server settings", - "host": "Host Address", - "port": "Port" + "title": "Настройки сервера", + "description": "Базовые параметры сервера", + "host": "Адрес хоста", + "port": "Порт" }, "tls": { - "title": "TLS/SSL Configuration", - "description": "HTTPS secure connection settings", - "enable": "Enable TLS", - "enable_desc": "Enable HTTPS secure connection", - "cert": "Certificate File Path", - "key": "Private Key File Path" + "title": "Настройка TLS/SSL", + "description": "Параметры безопасного HTTPS-соединения", + "enable": "Включить TLS", + "enable_desc": "Включить безопасное HTTPS-соединение", + "cert": "Путь к сертификату", + "key": "Путь к закрытому ключу" }, "remote": { - "title": "Remote Management", - "description": "Remote access and control panel settings", - "allow_remote": "Allow Remote Access", - "allow_remote_desc": "Allow management access from other hosts", - "disable_panel": "Disable Control Panel", - "disable_panel_desc": "Disable the built-in web control panel", - "secret_key": "Management Key", - "secret_key_placeholder": "Set management key", - "panel_repo": "Panel Repository" + "title": "Удалённое управление", + "description": "Настройки удалённого доступа и панели управления", + "allow_remote": "Разрешить удалённый доступ", + "allow_remote_desc": "Разрешить управление с других хостов", + "disable_panel": "Отключить панель", + "disable_panel_desc": "Отключить встроенную веб-панель управления", + "secret_key": "Ключ управления", + "secret_key_placeholder": "Задайте ключ управления", + "panel_repo": "Репозиторий панели" }, "auth": { - "title": "Authentication Configuration", - "description": "API keys and authentication directory settings", - "auth_dir": "Auth Directory (auth-dir)", - "auth_dir_hint": "Directory path for authentication files (supports ~)" + "title": "Настройки аутентификации", + "description": "API-ключи и каталог аутентификации", + "auth_dir": "Каталог auth-dir", + "auth_dir_hint": "Путь к каталогу с файлами аутентификации (поддерживает ~)" }, "system": { - "title": "System Configuration", - "description": "Debug, logging, statistics, and performance settings", - "debug": "Debug Mode", - "debug_desc": "Enable verbose debug logging", - "commercial_mode": "Commercial Mode", - "commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency", - "logging_to_file": "Log to File", - "logging_to_file_desc": "Save logs to rotating files", - "usage_statistics": "Usage Statistics", - "usage_statistics_desc": "Collect usage statistics", - "logs_max_size": "Log File Size Limit (MB)", - "usage_retention_days": "Usage Records Retention Days", - "usage_retention_hint": "0 means no limit (no cleanup)" + "title": "Системные настройки", + "description": "Отладка, журналирование, статистика и производительность", + "debug": "Режим отладки", + "debug_desc": "Включить подробные отладочные журналы", + "commercial_mode": "Коммерческий режим", + "commercial_mode_desc": "Отключить тяжёлое промежуточное ПО, чтобы снизить расход памяти при высокой нагрузке", + "logging_to_file": "Журналировать в файл", + "logging_to_file_desc": "Сохранять журналы во вращающиеся файлы", + "usage_statistics": "Статистика использования", + "usage_statistics_desc": "Собирать статистику использования", + "logs_max_size": "Максимальный размер файла журнала (МБ)", + "usage_retention_days": "Хранить статистику (дней)", + "usage_retention_hint": "0 означает без ограничений (без очистки)" }, "network": { - "title": "Network Configuration", - "description": "Proxy, retry, and routing settings", - "proxy_url": "Proxy URL", - "request_retry": "Request Retry Count", - "max_retry_interval": "Max Retry Interval (seconds)", - "routing_strategy": "Routing Strategy", - "routing_strategy_hint": "Select credential selection strategy", - "strategy_round_robin": "Round Robin", - "strategy_fill_first": "Fill First", - "force_model_prefix": "Force Model Prefix", - "force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix", - "ws_auth": "WebSocket Authentication", - "ws_auth_desc": "Enable WebSocket authentication (/v1/ws)" + "title": "Сетевые настройки", + "description": "Параметры прокси, повторов и маршрутизации", + "proxy_url": "URL прокси", + "request_retry": "Количество повторов запросов", + "max_retry_interval": "Максимальный интервал повтора (сек)", + "routing_strategy": "Стратегия маршрутизации", + "routing_strategy_hint": "Выберите стратегию подбора учётных данных", + "strategy_round_robin": "По кругу", + "strategy_fill_first": "Сначала заполнить", + "force_model_prefix": "Принудительный префикс модели", + "force_model_prefix_desc": "Запросы к моделям без префикса используют только учётные данные без префикса", + "ws_auth": "Аутентификация WebSocket", + "ws_auth_desc": "Включить аутентификацию WebSocket (/v1/ws)" }, "quota": { - "title": "Quota Fallback", - "description": "Fallback strategy when quota is exceeded", - "switch_project": "Switch Project", - "switch_project_desc": "Automatically switch to another project when quota is exceeded", - "switch_preview_model": "Switch to Preview Model", - "switch_preview_model_desc": "Switch to preview model version when quota is exceeded" + "title": "Резерв по квоте", + "description": "Стратегия при превышении квоты", + "switch_project": "Переключить проект", + "switch_project_desc": "Автоматически переходить на другой проект при превышении квоты", + "switch_preview_model": "Переключить на preview-модель", + "switch_preview_model_desc": "Переключаться на preview-версию модели при превышении квоты" }, "streaming": { - "title": "Streaming Configuration", - "description": "Keepalive and bootstrap retry settings", - "keepalive_seconds": "Keepalive Seconds", - "keepalive_hint": "Set to 0 or leave empty to disable keepalive", - "bootstrap_retries": "Bootstrap Retries", - "bootstrap_hint": "Number of retries during stream startup (before first byte)", - "nonstream_keepalive": "Non-stream Keepalive Interval (seconds)", - "nonstream_keepalive_hint": "Send blank lines every N seconds for non-streaming responses to prevent idle timeout, set to 0 or leave empty to disable", - "disabled": "Disabled" + "title": "Настройки стриминга", + "description": "Параметры keepalive и повторов запуска", + "keepalive_seconds": "Период keepalive (сек)", + "keepalive_hint": "Установите 0 или оставьте поле пустым, чтобы отключить keepalive", + "bootstrap_retries": "Повторы запуска", + "bootstrap_hint": "Количество попыток при запуске стрима (до первого байта)", + "nonstream_keepalive": "Интервал keepalive для нестиминговых ответов (сек)", + "nonstream_keepalive_hint": "Отправлять пустые строки каждые N секунд для нестиминговых ответов, чтобы избежать простоя; установите 0 или оставьте пустым для отключения", + "disabled": "Отключено" }, "payload": { - "title": "Payload Configuration", - "description": "Default values, override rules, and filter rules", - "default_rules": "Default Rules", - "default_rules_desc": "Use these default values when parameters are not specified in the request", - "override_rules": "Override Rules", - "override_rules_desc": "Force override parameter values in the request", - "filter_rules": "Filter Rules", - "filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)" + "title": "Настройки полезной нагрузки", + "description": "Значения по умолчанию, правила переопределения и фильтрации", + "default_rules": "Правила по умолчанию", + "default_rules_desc": "Использовать эти значения, если параметр не указан в запросе", + "override_rules": "Правила переопределения", + "override_rules_desc": "Принудительно задавать значения параметров в запросе", + "filter_rules": "Правила фильтрации", + "filter_rules_desc": "Предварительно фильтровать тело исходящего запроса через JSON Path, автоматически удалять несоответствующие или лишние параметры (санитизация запроса)" } }, "api_keys": { - "label": "API Keys List (api-keys)", - "add": "Add API Key", - "empty": "No API keys", - "hint": "Each entry represents an API key (consistent with 'API Key Management' page style)", - "edit_title": "Edit API Key", - "add_title": "Add API Key", - "input_label": "API Key", - "input_placeholder": "Paste your API key", - "input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface", - "error_empty": "Please enter an API key", - "error_invalid": "API key contains invalid characters" + "label": "Список API-ключей (api-keys)", + "add": "Добавить API-ключ", + "empty": "API-ключи отсутствуют", + "hint": "Каждая запись — это API-ключ (в том же стиле, что и на странице управления API-ключами)", + "edit_title": "Редактирование API-ключа", + "add_title": "Добавление API-ключа", + "input_label": "API-ключ", + "input_placeholder": "Вставьте API-ключ", + "input_hint": "Меняет только содержимое локального файла конфигурации, не синхронизируется с интерфейсом управления API-ключами", + "error_empty": "Введите API-ключ", + "error_invalid": "API-ключ содержит недопустимые символы" }, "payload_rules": { - "rule": "Rule", - "models": "Applicable Models", - "model_name": "Model Name", - "provider_type": "Provider Type", - "add_model": "Add Model", - "params": "Parameter Settings", - "remove_params": "Remove Parameters", - "json_path": "JSON Path (e.g., temperature)", - "json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget", - "param_type": "Parameter Type", - "add_param": "Add Parameter", - "no_rules": "No rules", - "add_rule": "Add Rule", - "value_string": "String value", - "value_number": "Number value (e.g., 0.7)", - "value_boolean": "true or false", - "value_json": "JSON value", - "value_default": "Value" + "rule": "Правило", + "models": "Применимые модели", + "model_name": "Название модели", + "provider_type": "Тип провайдера", + "add_model": "Добавить модель", + "params": "Настройки параметров", + "remove_params": "Удалить параметры", + "json_path": "JSON Path (например, temperature)", + "json_path_filter": "JSON Path (gjson/sjson), например generationConfig.thinkingConfig.thinkingBudget", + "param_type": "Тип параметра", + "add_param": "Добавить параметр", + "no_rules": "Правил нет", + "add_rule": "Добавить правило", + "value_string": "Строковое значение", + "value_number": "Числовое значение (например, 0.7)", + "value_boolean": "true или false", + "value_json": "Значение JSON", + "value_default": "Значение" }, "common": { - "edit": "Edit", - "delete": "Delete", - "cancel": "Cancel", - "update": "Update", - "add": "Add" + "edit": "Изменить", + "delete": "Удалить", + "cancel": "Отменить", + "update": "Обновить", + "add": "Добавить" } } }, "quota_management": { - "title": "Quota Management", - "description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.", - "refresh_files": "Refresh auth files", - "refresh_files_and_quota": "Refresh files & quota" + "title": "Управление квотами", + "description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.", + "refresh_files": "Обновить файлы авторизации", + "refresh_files_and_quota": "Обновить файлы и квоты" }, "system_info": { - "title": "Management Center Info", - "connection_status_title": "Connection Status", - "api_status_label": "API Status:", - "config_status_label": "Config Status:", - "last_update_label": "Last Update:", - "cache_data": "Cache Data", - "real_time_data": "Real-time Data", - "not_loaded": "Not Loaded", - "seconds_ago": "seconds ago", - "models_title": "Available Models", - "models_desc": "Shows the /models response and uses saved API keys for auth automatically.", - "models_loading": "Loading available models...", - "models_empty": "No models returned by /models", - "models_error": "Failed to load model list", - "models_count": "{{count}} available models", - "version_check_title": "Update Check", - "version_check_desc": "Call the /latest-version endpoint to compare with the server version and see if an update is available.", - "version_current_label": "Current version", - "version_latest_label": "Latest version", - "version_check_button": "Check for updates", - "version_check_idle": "Click to check for updates", - "version_checking": "Checking for the latest version...", - "version_update_available": "An update is available: {{version}}", - "version_is_latest": "You are on the latest version", - "version_check_error": "Update check failed", - "version_current_missing": "Server version is unavailable; cannot compare", - "version_unknown": "Unknown", - "quick_links_title": "Quick Links", - "quick_links_desc": "Access project repositories and documentation for help and updates.", - "link_main_repo": "Main Repository", - "link_main_repo_desc": "CLI Proxy API core program source code", - "link_webui_repo": "WebUI Repository", - "link_webui_repo_desc": "Management Center frontend source code", - "link_docs": "Documentation", - "link_docs_desc": "Usage tutorials and configuration guides", - "clear_login_title": "Local Login Data", - "clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.", - "clear_login_button": "Clear login data", - "clear_login_confirm": "Clear local login data and sign out now?" + "title": "Информация о центре управления", + "connection_status_title": "Статус подключения", + "api_status_label": "Статус API:", + "config_status_label": "Статус конфигурации:", + "last_update_label": "Последнее обновление:", + "cache_data": "Данные из кэша", + "real_time_data": "Данные в реальном времени", + "not_loaded": "Не загружено", + "seconds_ago": "секунд назад", + "models_title": "Доступные модели", + "models_desc": "Показывает ответ /models и автоматически использует сохранённые API-ключи для авторизации.", + "models_loading": "Загрузка доступных моделей...", + "models_empty": "Сервис /models не вернул модели", + "models_error": "Не удалось загрузить список моделей", + "models_count": "Доступно моделей: {{count}}", + "version_check_title": "Проверка обновлений", + "version_check_desc": "Вызовите эндпоинт /latest-version, чтобы сравнить с версией сервера и узнать о доступных обновлениях.", + "version_current_label": "Текущая версия", + "version_latest_label": "Последняя версия", + "version_check_button": "Проверить обновления", + "version_check_idle": "Нажмите, чтобы проверить обновления", + "version_checking": "Поиск последней версии...", + "version_update_available": "Доступно обновление: {{version}}", + "version_is_latest": "Установлена последняя версия", + "version_check_error": "Не удалось проверить обновление", + "version_current_missing": "Версия сервера недоступна; сравнение невозможно", + "version_unknown": "Неизвестно", + "quick_links_title": "Быстрые ссылки", + "quick_links_desc": "Доступ к репозиториям проекта и документации для помощи и обновлений.", + "link_main_repo": "Основной репозиторий", + "link_main_repo_desc": "Исходный код основной программы CLI Proxy API", + "link_webui_repo": "Репозиторий WebUI", + "link_webui_repo_desc": "Исходный код фронтенда центра управления", + "link_docs": "Документация", + "link_docs_desc": "Учебные пособия и руководства по настройке", + "clear_login_title": "Локальные данные входа", + "clear_login_desc": "Очистите локально сохранённые данные входа и выполните выход. Настройки стоимости статистики использования сохранятся.", + "clear_login_button": "Очистить данные входа", + "clear_login_confirm": "Очистить локальные данные входа и выйти?" }, "notification": { - "debug_updated": "Debug settings updated", - "proxy_updated": "Proxy settings updated", - "proxy_cleared": "Proxy settings cleared", - "retry_updated": "Retry settings updated", - "quota_switch_project_updated": "Project switch settings updated", - "quota_switch_preview_updated": "Preview model switch settings updated", - "usage_statistics_updated": "Usage statistics settings updated", - "logging_to_file_updated": "Logging settings updated", - "logs_max_total_size_updated": "Log size limit updated", - "request_log_updated": "Request logging setting updated", - "force_model_prefix_updated": "Model prefix setting updated", - "ws_auth_updated": "WebSocket authentication setting updated", - "routing_strategy_updated": "Routing strategy updated", - "login_storage_cleared": "Local login data cleared", - "api_key_added": "API key added successfully", - "api_key_updated": "API key updated successfully", - "api_key_deleted": "API key deleted successfully", - "api_key_invalid_chars": "API key can only contain letters, numbers, and symbols", - "gemini_key_added": "Gemini key added successfully", - "gemini_key_updated": "Gemini key updated successfully", - "gemini_key_deleted": "Gemini key deleted successfully", - "gemini_multi_input_required": "Please enter at least one Gemini key", - "gemini_multi_failed": "Gemini bulk add failed", - "gemini_multi_summary": "Gemini bulk add finished: {{success}} added, {{skipped}} skipped, {{failed}} failed", - "codex_config_added": "Codex configuration added successfully", - "codex_config_updated": "Codex configuration updated successfully", - "codex_config_deleted": "Codex configuration deleted successfully", - "codex_base_url_required": "Please enter the Codex Base URL", - "claude_config_added": "Claude configuration added successfully", - "claude_config_updated": "Claude configuration updated successfully", - "claude_config_deleted": "Claude configuration deleted successfully", - "vertex_config_added": "Vertex configuration added successfully", - "vertex_config_updated": "Vertex configuration updated successfully", - "vertex_config_deleted": "Vertex configuration deleted successfully", - "vertex_base_url_required": "Please enter the Vertex Base URL", - "config_enabled": "Configuration enabled", - "config_disabled": "Configuration disabled", - "field_required": "Required fields cannot be empty", - "openai_provider_required": "Please fill in provider name and Base URL", - "openai_provider_added": "OpenAI provider added successfully", - "openai_provider_updated": "OpenAI provider updated successfully", - "openai_provider_deleted": "OpenAI provider deleted successfully", - "ampcode_updated": "Ampcode configuration updated", - "ampcode_upstream_api_key_cleared": "Ampcode upstream API key override cleared", - "openai_model_name_required": "Model name is required", - "openai_test_url_required": "Please provide a valid Base URL before testing", - "openai_test_key_required": "Please add at least one API key before testing", - "openai_test_model_required": "Please select a model to test", - "data_refreshed": "Data refreshed successfully", - "connection_required": "Please establish connection first", - "refresh_failed": "Refresh failed", - "update_failed": "Update failed", - "add_failed": "Add failed", - "delete_failed": "Delete failed", - "upload_failed": "Upload failed", - "download_failed": "Download failed", - "login_failed": "Login failed", - "please_enter": "Please enter", - "please_fill": "Please fill", - "provider_name_url": "provider name and Base URL", - "api_key": "API key", - "gemini_api_key": "Gemini API key", - "codex_api_key": "Codex API key", - "claude_api_key": "Claude API key", - "link_copied": "Link copied to clipboard" + "debug_updated": "Настройки отладки обновлены", + "proxy_updated": "Настройки прокси обновлены", + "proxy_cleared": "Настройки прокси очищены", + "retry_updated": "Настройки повторов обновлены", + "quota_switch_project_updated": "Настройки переключения проектов обновлены", + "quota_switch_preview_updated": "Настройки переключения на preview-модель обновлены", + "usage_statistics_updated": "Настройки статистики использования обновлены", + "logging_to_file_updated": "Настройки журналирования обновлены", + "logs_max_total_size_updated": "Лимит размера журналов обновлён", + "request_log_updated": "Настройка журналирования запросов обновлена", + "force_model_prefix_updated": "Настройка префикса модели обновлена", + "ws_auth_updated": "Настройка аутентификации WebSocket обновлена", + "routing_strategy_updated": "Стратегия маршрутизации обновлена", + "login_storage_cleared": "Локальные данные входа очищены", + "api_key_added": "API-ключ успешно добавлен", + "api_key_updated": "API-ключ успешно обновлён", + "api_key_deleted": "API-ключ успешно удалён", + "api_key_invalid_chars": "API-ключ может содержать только буквы, цифры и символы", + "gemini_key_added": "Ключ Gemini успешно добавлен", + "gemini_key_updated": "Ключ Gemini успешно обновлён", + "gemini_key_deleted": "Ключ Gemini успешно удалён", + "gemini_multi_input_required": "Введите хотя бы один ключ Gemini", + "gemini_multi_failed": "Пакетное добавление Gemini не удалось", + "gemini_multi_summary": "Пакетное добавление Gemini завершено: добавлено {{success}}, пропущено {{skipped}}, ошибок {{failed}}", + "codex_config_added": "Конфигурация Codex успешно добавлена", + "codex_config_updated": "Конфигурация Codex успешно обновлена", + "codex_config_deleted": "Конфигурация Codex успешно удалена", + "codex_base_url_required": "Введите базовый URL Codex", + "claude_config_added": "Конфигурация Claude успешно добавлена", + "claude_config_updated": "Конфигурация Claude успешно обновлена", + "claude_config_deleted": "Конфигурация Claude успешно удалена", + "vertex_config_added": "Конфигурация Vertex успешно добавлена", + "vertex_config_updated": "Конфигурация Vertex успешно обновлена", + "vertex_config_deleted": "Конфигурация Vertex успешно удалена", + "vertex_base_url_required": "Введите базовый URL Vertex", + "config_enabled": "Конфигурация включена", + "config_disabled": "Конфигурация выключена", + "field_required": "Обязательные поля не могут быть пустыми", + "openai_provider_required": "Заполните имя провайдера и базовый URL", + "openai_provider_added": "Провайдер OpenAI успешно добавлен", + "openai_provider_updated": "Провайдер OpenAI успешно обновлён", + "openai_provider_deleted": "Провайдер OpenAI успешно удалён", + "ampcode_updated": "Настройки Ampcode обновлены", + "ampcode_upstream_api_key_cleared": "Переопределение upstream-ключа Ampcode очищено", + "openai_model_name_required": "Введите имя модели", + "openai_test_url_required": "Укажите корректный базовый URL перед тестированием", + "openai_test_key_required": "Добавьте хотя бы один API-ключ перед тестированием", + "openai_test_model_required": "Выберите модель для теста", + "data_refreshed": "Данные успешно обновлены", + "connection_required": "Сначала установите подключение", + "refresh_failed": "Не удалось обновить", + "update_failed": "Не удалось обновить", + "add_failed": "Не удалось добавить", + "delete_failed": "Не удалось удалить", + "upload_failed": "Не удалось загрузить", + "download_failed": "Не удалось скачать", + "login_failed": "Вход не выполнен", + "please_enter": "Пожалуйста, введите", + "please_fill": "Пожалуйста, заполните", + "provider_name_url": "имя провайдера и базовый URL", + "api_key": "API-ключ", + "gemini_api_key": "API-ключ Gemini", + "codex_api_key": "API-ключ Codex", + "claude_api_key": "API-ключ Claude", + "link_copied": "Ссылка скопирована в буфер обмена" }, "language": { - "switch": "Language", + "switch": "Язык", "chinese": "中文", - "english": "English" + "english": "English", + "russian": "Русский" }, "theme": { - "switch": "Theme", - "light": "Light", - "dark": "Dark", - "switch_to_light": "Switch to light mode", - "switch_to_dark": "Switch to dark mode", - "auto": "Follow system" + "switch": "Тема", + "light": "Светлая", + "dark": "Тёмная", + "switch_to_light": "Переключиться на светлую тему", + "switch_to_dark": "Переключиться на тёмную тему", + "auto": "Следовать системе" }, "sidebar": { - "toggle_expand": "Expand sidebar", - "toggle_collapse": "Collapse sidebar" + "toggle_expand": "Развернуть боковую панель", + "toggle_collapse": "Свернуть боковую панель" }, "footer": { - "api_version": "CLI Proxy API Version", - "build_date": "Build Time", - "version": "Management UI Version", - "author": "Author" + "api_version": "Версия CLI Proxy API", + "build_date": "Время сборки", + "version": "Версия интерфейса управления", + "author": "Автор" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 20f8a7f..42d1a7a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1090,7 +1090,8 @@ "language": { "switch": "语言", "chinese": "中文", - "english": "English" + "english": "English", + "russian": "Русский" }, "theme": { "switch": "主题", diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 255af84..39f4cc4 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -7,7 +7,7 @@ import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; -import type { ApiError } from '@/types'; +import type { ApiError, Language } from '@/types'; import styles from './LoginPage.module.scss'; /** @@ -78,7 +78,14 @@ export function LoginPage() { const [error, setError] = useState(''); const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); - const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese'); + const nextLanguage: Language = language === 'zh-CN' ? 'en' : language === 'en' ? 'ru' : 'zh-CN'; + const nextLanguageLabel = t( + nextLanguage === 'zh-CN' + ? 'language.chinese' + : nextLanguage === 'en' + ? 'language.english' + : 'language.russian' + ); useEffect(() => { const init = async () => { diff --git a/src/stores/useLanguageStore.ts b/src/stores/useLanguageStore.ts index 9de3bed..e04a516 100644 --- a/src/stores/useLanguageStore.ts +++ b/src/stores/useLanguageStore.ts @@ -29,8 +29,10 @@ export const useLanguageStore = create()( toggleLanguage: () => { const { language, setLanguage } = get(); - const newLanguage: Language = language === 'zh-CN' ? 'en' : 'zh-CN'; - setLanguage(newLanguage); + const order: Language[] = ['zh-CN', 'en', 'ru']; + const currentIndex = order.indexOf(language); + const nextLanguage = order[(currentIndex + 1) % order.length]; + setLanguage(nextLanguage); } }), { From d5ccef8b24b1a7e4c6e080149f670e72b82779f1 Mon Sep 17 00:00:00 2001 From: Chebotov Nickolay Date: Fri, 6 Feb 2026 12:29:23 +0300 Subject: [PATCH 03/24] chore: restore package lock --- package-lock.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/package-lock.json b/package-lock.json index b8645c8..2ce56de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -466,6 +467,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1931,6 +1933,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2018,6 +2021,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -2335,6 +2339,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2546,6 +2551,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2810,6 +2816,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3286,6 +3293,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3615,6 +3623,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3721,6 +3730,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3738,6 +3748,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3846,6 +3857,7 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4028,6 +4040,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4104,6 +4117,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4233,6 +4247,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -4260,6 +4275,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From f833f0dfd2e1fd57ee56ee25656052c70da60ea2 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 6 Feb 2026 18:14:13 +0800 Subject: [PATCH 04/24] fix(config): align visual editor with backend config semantics --- package-lock.json | 13 +++++ package.json | 1 + src/components/config/VisualConfigEditor.tsx | 9 ---- src/hooks/useVisualConfig.ts | 57 ++++++++++++-------- src/i18n/locales/en.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/pages/ConfigPage.tsx | 5 +- src/types/visualConfig.ts | 2 - 8 files changed, 53 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ce56de..a182545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@codemirror/lang-yaml": "^6.1.2", + "@openai/codex": "^0.98.0", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", @@ -1243,6 +1244,18 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@openai/codex": { + "version": "0.98.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz", + "integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", diff --git a/package.json b/package.json index b8d6a4d..70a1935 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@codemirror/lang-yaml": "^6.1.2", + "@openai/codex": "^0.98.0", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index eff48ed..d66e9f7 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -891,15 +891,6 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })} disabled={disabled} /> - onChange({ usageRecordsRetentionDays: e.target.value })} - disabled={disabled} - hint={t('config_management.visual.sections.system.usage_retention_hint')} - /> diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts index 1f7ed30..47d3cce 100644 --- a/src/hooks/useVisualConfig.ts +++ b/src/hooks/useVisualConfig.ts @@ -102,6 +102,27 @@ function deepClone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } { + if (typeof raw === 'number') { + return { valueType: 'number', value: String(raw) }; + } + + if (typeof raw === 'boolean') { + return { valueType: 'boolean', value: String(raw) }; + } + + if (raw === null || typeof raw === 'object') { + try { + const json = JSON.stringify(raw, null, 2); + return { valueType: 'json', value: json ?? 'null' }; + } catch { + return { valueType: 'json', value: String(raw) }; + } + } + + return { valueType: 'string', value: String(raw ?? '') }; +} + function parsePayloadRules(rules: unknown): PayloadRule[] { if (!Array.isArray(rules)) return []; @@ -115,19 +136,15 @@ function parsePayloadRules(rules: unknown): PayloadRule[] { })) : [], params: (rule as any)?.params - ? Object.entries((rule as any).params as Record).map(([path, value], pIndex) => ({ - id: `param-${index}-${pIndex}`, - path, - valueType: - typeof value === 'number' - ? 'number' - : typeof value === 'boolean' - ? 'boolean' - : typeof value === 'object' - ? 'json' - : 'string', - value: String(value), - })) + ? Object.entries((rule as any).params as Record).map(([path, value], pIndex) => { + const parsedValue = parsePayloadParamValue(value); + return { + id: `param-${index}-${pIndex}`, + path, + valueType: parsedValue.valueType, + value: parsedValue.value, + }; + }) : [], })); } @@ -220,7 +237,7 @@ export function useVisualConfig() { const newValues: VisualConfigValues = { host: parsed.host || '', - port: String(parsed.port || ''), + port: String(parsed.port ?? ''), tlsEnable: Boolean(parsed.tls?.enable), tlsCert: parsed.tls?.cert || '', @@ -240,14 +257,13 @@ export function useVisualConfig() { debug: Boolean(parsed.debug), commercialMode: Boolean(parsed['commercial-mode']), loggingToFile: Boolean(parsed['logging-to-file']), - logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] || ''), + logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''), usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']), - usageRecordsRetentionDays: String(parsed['usage-records-retention-days'] ?? ''), proxyUrl: parsed['proxy-url'] || '', forceModelPrefix: Boolean(parsed['force-model-prefix']), - requestRetry: String(parsed['request-retry'] || ''), - maxRetryInterval: String(parsed['max-retry-interval'] || ''), + requestRetry: String(parsed['request-retry'] ?? ''), + maxRetryInterval: String(parsed['max-retry-interval'] ?? ''), wsAuth: Boolean(parsed['ws-auth']), quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true), @@ -333,11 +349,6 @@ export function useVisualConfig() { setBoolean(parsed, 'logging-to-file', values.loggingToFile); setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb); setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled); - setIntFromString( - parsed, - 'usage-records-retention-days', - values.usageRecordsRetentionDays - ); setString(parsed, 'proxy-url', values.proxyUrl); setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6b90898..c58692d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -882,9 +882,7 @@ "logging_to_file_desc": "Save logs to rotating files", "usage_statistics": "Usage Statistics", "usage_statistics_desc": "Collect usage statistics", - "logs_max_size": "Log File Size Limit (MB)", - "usage_retention_days": "Usage Records Retention Days", - "usage_retention_hint": "0 means no limit (no cleanup)" + "logs_max_size": "Log File Size Limit (MB)" }, "network": { "title": "Network Configuration", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 20f8a7f..ed769c9 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -882,9 +882,7 @@ "logging_to_file_desc": "将日志保存到滚动文件", "usage_statistics": "使用统计", "usage_statistics_desc": "收集使用统计信息", - "logs_max_size": "日志文件大小限制 (MB)", - "usage_retention_days": "使用记录保留天数", - "usage_retention_hint": "0 为无限制(不清理)" + "logs_max_size": "日志文件大小限制 (MB)" }, "network": { "title": "网络配置", diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 77e3a00..eaf9442 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -80,9 +80,10 @@ export function ConfigPage() { try { const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content; await configFileApi.saveConfigYaml(nextContent); + const latestContent = await configFileApi.fetchConfigYaml(); setDirty(false); - setContent(nextContent); - loadVisualValuesFromYaml(nextContent); + setContent(latestContent); + loadVisualValuesFromYaml(latestContent); showNotification(t('config_management.save_success'), 'success'); } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts index 86efcf9..ff4242a 100644 --- a/src/types/visualConfig.ts +++ b/src/types/visualConfig.ts @@ -48,7 +48,6 @@ export type VisualConfigValues = { loggingToFile: boolean; logsMaxTotalSizeMb: string; usageStatisticsEnabled: boolean; - usageRecordsRetentionDays: string; proxyUrl: string; forceModelPrefix: boolean; requestRetry: string; @@ -85,7 +84,6 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = { loggingToFile: false, logsMaxTotalSizeMb: '', usageStatisticsEnabled: false, - usageRecordsRetentionDays: '', proxyUrl: '', forceModelPrefix: false, requestRetry: '', From 3661530f5fb3dea68225b02e78f09120c9220e0d Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 6 Feb 2026 18:38:37 +0800 Subject: [PATCH 05/24] fix(ui): make payload visual editor responsive on mobile --- package-lock.json | 1 - package.json | 1 - .../config/VisualConfigEditor.module.scss | 37 ++++++++++++ src/components/config/VisualConfigEditor.tsx | 59 +++++++++++++++---- 4 files changed, 83 insertions(+), 15 deletions(-) create mode 100644 src/components/config/VisualConfigEditor.module.scss diff --git a/package-lock.json b/package-lock.json index a182545..cc39202 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.0", "dependencies": { "@codemirror/lang-yaml": "^6.1.2", - "@openai/codex": "^0.98.0", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", diff --git a/package.json b/package.json index 70a1935..b8d6a4d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@codemirror/lang-yaml": "^6.1.2", - "@openai/codex": "^0.98.0", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", diff --git a/src/components/config/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss new file mode 100644 index 0000000..cf4e77d --- /dev/null +++ b/src/components/config/VisualConfigEditor.module.scss @@ -0,0 +1,37 @@ +.payloadRuleModelRow { + display: grid; + grid-template-columns: 1fr 160px auto; + gap: 8px; + align-items: center; +} + +.payloadRuleModelRowProtocolFirst { + grid-template-columns: 160px 1fr auto; +} + +.payloadRuleParamRow { + display: grid; + grid-template-columns: 1fr 140px 1fr auto; + gap: 8px; + align-items: center; +} + +.payloadFilterModelRow { + display: grid; + grid-template-columns: 1fr 160px auto; + gap: 8px; + align-items: center; +} + +@media (max-width: 900px) { + .payloadRuleModelRow, + .payloadRuleModelRowProtocolFirst, + .payloadRuleParamRow, + .payloadFilterModelRow { + grid-template-columns: minmax(0, 1fr); + } + + .payloadRowActionButton { + width: 100%; + } +} diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index d66e9f7..7691045 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -6,6 +6,7 @@ import { Modal } from '@/components/ui/Modal'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconChevronDown } from '@/components/ui/icons'; import { ConfigSection } from '@/components/config/ConfigSection'; +import styles from './VisualConfigEditor.module.scss'; import type { PayloadFilterRule, PayloadModelEntry, @@ -358,7 +359,7 @@ function StringListEditor({ return (
{items.map((item, index) => ( -
+
-
+
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
@@ -547,7 +560,7 @@ function PayloadRulesEditor({
{t('config_management.visual.payload_rules.params')}
{(rule.params.length ? rule.params : []).map((param, paramIndex) => ( -
+
updateParam(ruleIndex, paramIndex, { value: e.target.value })} disabled={disabled} /> -
@@ -658,7 +677,15 @@ function PayloadFilterRulesEditor({ gap: 12, }} > -
+
{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}
From cade2647d637451ad5301ff5204c6eac71bd098a Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 6 Feb 2026 19:11:57 +0800 Subject: [PATCH 06/24] feat(quota): add normalization for Gemini CLI model IDs and update quota groups --- src/components/quota/quotaConfigs.ts | 3 +- src/utils/quota/builders.ts | 58 +++++++++++++++++++--------- src/utils/quota/constants.ts | 12 +++++- src/utils/quota/parsers.ts | 11 ++++++ 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/components/quota/quotaConfigs.ts b/src/components/quota/quotaConfigs.ts index abdac0e..e9765aa 100644 --- a/src/components/quota/quotaConfigs.ts +++ b/src/components/quota/quotaConfigs.ts @@ -28,6 +28,7 @@ import { GEMINI_CLI_QUOTA_URL, GEMINI_CLI_REQUEST_HEADERS, normalizeAuthIndexValue, + normalizeGeminiCliModelId, normalizeNumberValue, normalizePlanType, normalizeQuotaFraction, @@ -368,7 +369,7 @@ const fetchGeminiCliQuota = async ( const parsedBuckets = buckets .map((bucket) => { - const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id); + const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id); if (!modelId) return null; const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type); const remainingFractionRaw = normalizeQuotaFraction( diff --git a/src/utils/quota/builders.ts b/src/utils/quota/builders.ts index 39d325f..95351af 100644 --- a/src/utils/quota/builders.ts +++ b/src/utils/quota/builders.ts @@ -10,7 +10,11 @@ import type { GeminiCliParsedBucket, GeminiCliQuotaBucketState, } from '@/types'; -import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants'; +import { + ANTIGRAVITY_QUOTA_GROUPS, + GEMINI_CLI_GROUP_LOOKUP, + GEMINI_CLI_GROUP_ORDER, +} from './constants'; import { normalizeQuotaFraction } from './parsers'; import { isIgnoredGeminiCliModel } from './validators'; @@ -92,24 +96,40 @@ export function buildGeminiCliQuotaBuckets( } }); - return Array.from(grouped.values()).map((bucket) => { - const uniqueModelIds = Array.from(new Set(bucket.modelIds)); - const preferred = bucket.preferredBucket; - const remainingFraction = preferred - ? preferred.remainingFraction - : bucket.fallbackRemainingFraction; - const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount; - const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime; - return { - id: bucket.id, - label: bucket.label, - remainingFraction, - remainingAmount, - resetTime, - tokenType: bucket.tokenType, - modelIds: uniqueModelIds, - }; - }); + const toGroupOrder = (bucket: GeminiCliQuotaBucketGroup): number => { + const tokenSuffix = bucket.tokenType ? `-${bucket.tokenType}` : ''; + const groupId = bucket.id.endsWith(tokenSuffix) + ? bucket.id.slice(0, bucket.id.length - tokenSuffix.length) + : bucket.id; + return GEMINI_CLI_GROUP_ORDER.get(groupId) ?? Number.MAX_SAFE_INTEGER; + }; + + return Array.from(grouped.values()) + .sort((a, b) => { + const orderDiff = toGroupOrder(a) - toGroupOrder(b); + if (orderDiff !== 0) return orderDiff; + const tokenTypeA = a.tokenType ?? ''; + const tokenTypeB = b.tokenType ?? ''; + return tokenTypeA.localeCompare(tokenTypeB); + }) + .map((bucket) => { + const uniqueModelIds = Array.from(new Set(bucket.modelIds)); + const preferred = bucket.preferredBucket; + const remainingFraction = preferred + ? preferred.remainingFraction + : bucket.fallbackRemainingFraction; + const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount; + const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime; + return { + id: bucket.id, + label: bucket.label, + remainingFraction, + remainingAmount, + resetTime, + tokenType: bucket.tokenType, + modelIds: uniqueModelIds, + }; + }); } export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): { diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index 330af65..f9b8e1d 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -119,11 +119,17 @@ export const GEMINI_CLI_REQUEST_HEADERS = { }; export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [ + { + id: 'gemini-flash-lite-series', + label: 'Gemini Flash Lite Series', + preferredModelId: 'gemini-2.5-flash-lite', + modelIds: ['gemini-2.5-flash-lite'], + }, { id: 'gemini-flash-series', label: 'Gemini Flash Series', preferredModelId: 'gemini-3-flash-preview', - modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'], + modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash'], }, { id: 'gemini-pro-series', @@ -133,6 +139,10 @@ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [ }, ]; +export const GEMINI_CLI_GROUP_ORDER = new Map( + GEMINI_CLI_QUOTA_GROUPS.map((group, index) => [group.id, index] as const) +); + export const GEMINI_CLI_GROUP_LOOKUP = new Map( GEMINI_CLI_QUOTA_GROUPS.flatMap((group) => group.modelIds.map((modelId) => [modelId, group] as const) diff --git a/src/utils/quota/parsers.ts b/src/utils/quota/parsers.ts index 2383833..748a3a9 100644 --- a/src/utils/quota/parsers.ts +++ b/src/utils/quota/parsers.ts @@ -4,6 +4,8 @@ import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types'; +const GEMINI_CLI_MODEL_SUFFIX = '_vertex'; + export function normalizeAuthIndexValue(value: unknown): string | null { if (typeof value === 'number' && Number.isFinite(value)) { return value.toString(); @@ -26,6 +28,15 @@ export function normalizeStringValue(value: unknown): string | null { return null; } +export function normalizeGeminiCliModelId(value: unknown): string | null { + const modelId = normalizeStringValue(value); + if (!modelId) return null; + if (modelId.endsWith(GEMINI_CLI_MODEL_SUFFIX)) { + return modelId.slice(0, -GEMINI_CLI_MODEL_SUFFIX.length); + } + return modelId; +} + export function normalizeNumberValue(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { From 0bb8090686cb256ca8e9c9762f8d7fc96bb43ed9 Mon Sep 17 00:00:00 2001 From: Chebotov Nickolay Date: Fri, 6 Feb 2026 15:08:53 +0300 Subject: [PATCH 07/24] fix: address language review feedback --- src/i18n/locales/ru.json | 6 +++--- src/pages/LoginPage.tsx | 12 ++++-------- src/stores/useLanguageStore.ts | 7 +++---- src/utils/constants.ts | 11 +++++++++++ src/utils/language.ts | 10 +++++----- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 54954df..0945efc 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -47,8 +47,8 @@ "model_alias_placeholder": "Псевдоним модели (необязательно)" }, "title": { - "main": "CLI Proxy API Management Center", - "login": "CLI Proxy API Management Center", + "main": "Центр управления CLI Proxy API", + "login": "Центр управления CLI Proxy API", "abbr": "CPAMC" }, "auto_login": { @@ -108,7 +108,7 @@ }, "dashboard": { "title": "Панель управления", - "subtitle": "Добро пожаловать в CLI Proxy API Management Center", + "subtitle": "Добро пожаловать в Центр управления CLI Proxy API", "openai_providers": "Поставщики OpenAI", "quick_actions": "Быстрые действия", "current_config": "Текущая конфигурация", diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 39f4cc4..7290683 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/Input'; import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; +import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import type { ApiError, Language } from '@/types'; import styles from './LoginPage.module.scss'; @@ -78,14 +79,9 @@ export function LoginPage() { const [error, setError] = useState(''); const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); - const nextLanguage: Language = language === 'zh-CN' ? 'en' : language === 'en' ? 'ru' : 'zh-CN'; - const nextLanguageLabel = t( - nextLanguage === 'zh-CN' - ? 'language.chinese' - : nextLanguage === 'en' - ? 'language.english' - : 'language.russian' - ); + const nextLanguageIndex = LANGUAGE_ORDER.indexOf(language); + const nextLanguage: Language = LANGUAGE_ORDER[(nextLanguageIndex + 1) % LANGUAGE_ORDER.length]; + const nextLanguageLabel = t(LANGUAGE_LABEL_KEYS[nextLanguage]); useEffect(() => { const init = async () => { diff --git a/src/stores/useLanguageStore.ts b/src/stores/useLanguageStore.ts index e04a516..e80183e 100644 --- a/src/stores/useLanguageStore.ts +++ b/src/stores/useLanguageStore.ts @@ -6,7 +6,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { Language } from '@/types'; -import { STORAGE_KEY_LANGUAGE } from '@/utils/constants'; +import { LANGUAGE_ORDER, STORAGE_KEY_LANGUAGE } from '@/utils/constants'; import i18n from '@/i18n'; import { getInitialLanguage } from '@/utils/language'; @@ -29,9 +29,8 @@ export const useLanguageStore = create()( toggleLanguage: () => { const { language, setLanguage } = get(); - const order: Language[] = ['zh-CN', 'en', 'ru']; - const currentIndex = order.indexOf(language); - const nextLanguage = order[(currentIndex + 1) % order.length]; + const currentIndex = LANGUAGE_ORDER.indexOf(language); + const nextLanguage = LANGUAGE_ORDER[(currentIndex + 1) % LANGUAGE_ORDER.length]; setLanguage(nextLanguage); } }), diff --git a/src/utils/constants.ts b/src/utils/constants.ts index eb1b650..1b9ac60 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -3,6 +3,8 @@ * 从原项目 src/utils/constants.js 迁移 */ +import type { Language } from '@/types'; + // 缓存过期时间(毫秒) export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力 @@ -33,6 +35,15 @@ export const STORAGE_KEY_LANGUAGE = 'cli-proxy-language'; export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed'; export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size'; +// 语言配置 +export const LANGUAGE_ORDER: Language[] = ['zh-CN', 'en', 'ru']; +export const LANGUAGE_LABEL_KEYS: Record = { + 'zh-CN': 'language.chinese', + en: 'language.english', + ru: 'language.russian' +}; +export const SUPPORTED_LANGUAGES: Language[] = LANGUAGE_ORDER; + // 通知持续时间 export const NOTIFICATION_DURATION_MS = 3000; diff --git a/src/utils/language.ts b/src/utils/language.ts index 1975384..8aa1767 100644 --- a/src/utils/language.ts +++ b/src/utils/language.ts @@ -1,16 +1,16 @@ import type { Language } from '@/types'; -import { STORAGE_KEY_LANGUAGE } from '@/utils/constants'; +import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants'; const parseStoredLanguage = (value: string): Language | null => { try { const parsed = JSON.parse(value); const candidate = parsed?.state?.language ?? parsed?.language ?? parsed; - if (candidate === 'zh-CN' || candidate === 'en' || candidate === 'ru') { - return candidate; + if (SUPPORTED_LANGUAGES.includes(candidate as Language)) { + return candidate as Language; } } catch { - if (value === 'zh-CN' || value === 'en' || value === 'ru') { - return value; + if (SUPPORTED_LANGUAGES.includes(value as Language)) { + return value as Language; } } return null; From 50ab96c3ed203b8eae8fe99b89367c7c30a55f92 Mon Sep 17 00:00:00 2001 From: Chebotov Nickolay Date: Fri, 6 Feb 2026 15:20:25 +0300 Subject: [PATCH 08/24] feat: add language dropdown --- src/components/layout/MainLayout.tsx | 32 +++++++++++++++++++++---- src/pages/LoginPage.module.scss | 19 +++++++++++++-- src/pages/LoginPage.tsx | 29 +++++++++++++---------- src/styles/layout.scss | 35 ++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index dc263dd..d4802bb 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -35,6 +35,8 @@ import { } from '@/stores'; import { configApi, versionApi } from '@/services/api'; import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh'; +import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants'; +import type { Language } from '@/types'; const sidebarIcons: Record = { dashboard: , @@ -189,7 +191,15 @@ export function MainLayout() { const theme = useThemeStore((state) => state.theme); const cycleTheme = useThemeStore((state) => state.cycleTheme); - const toggleLanguage = useLanguageStore((state) => state.toggleLanguage); + const language = useLanguageStore((state) => state.language); + const setLanguage = useLanguageStore((state) => state.setLanguage); + + const handleLanguageChange = useCallback( + (event: React.ChangeEvent) => { + setLanguage(event.target.value as Language); + }, + [setLanguage] + ); const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); @@ -566,9 +576,23 @@ export function MainLayout() { > {headerIcons.update} - +
+ + +
+ {LANGUAGE_ORDER.map((lang) => ( + + ))} +
{t('login.subtitle')}
diff --git a/src/styles/layout.scss b/src/styles/layout.scss index 0ba8d9d..43feaf5 100644 --- a/src/styles/layout.scss +++ b/src/styles/layout.scss @@ -190,6 +190,41 @@ gap: $spacing-xs; flex-shrink: 0; + .language-select-wrapper { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + + &:hover { + color: var(--text-primary); + } + } + + .language-select-icon { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .language-select { + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: 10px 12px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + height: 40px; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + svg { display: block; } From c892d939c78f8551f37d894481253313b8293083 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 6 Feb 2026 22:28:01 +0800 Subject: [PATCH 09/24] feat(quota-ui): normalize Gemini vertex quota groups and streamline auth card refresh UX --- src/i18n/locales/en.json | 9 ++-- src/i18n/locales/zh-CN.json | 9 ++-- src/pages/AuthFilesPage.module.scss | 68 +++++++++-------------------- src/pages/AuthFilesPage.tsx | 38 +++++----------- 4 files changed, 36 insertions(+), 88 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c58692d..8b3df64 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -417,9 +417,6 @@ "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.", "prefix_proxy_saved_success": "Updated \"{{name}}\" successfully", - "card_tools_title": "Tools", - "quota_refresh_single": "Refresh quota", - "quota_refresh_hint": "Refresh quota for this credential only", "quota_refresh_success": "Quota refreshed for \"{{name}}\"", "quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}" }, @@ -427,7 +424,7 @@ "title": "Antigravity Quota", "empty_title": "No Antigravity Auth Files", "empty_desc": "Upload an Antigravity credential to view remaining quota.", - "idle": "Not loaded. Click Refresh Button.", + "idle": "Click here to refresh quota", "loading": "Loading quota...", "load_failed": "Failed to load quota: {{message}}", "missing_auth_index": "Auth file missing auth_index", @@ -439,7 +436,7 @@ "title": "Codex Quota", "empty_title": "No Codex Auth Files", "empty_desc": "Upload a Codex credential to view quota.", - "idle": "Not loaded. Click Refresh Button.", + "idle": "Click here to refresh quota", "loading": "Loading quota...", "load_failed": "Failed to load quota: {{message}}", "missing_auth_index": "Auth file missing auth_index", @@ -461,7 +458,7 @@ "title": "Gemini CLI Quota", "empty_title": "No Gemini CLI Auth Files", "empty_desc": "Upload a Gemini CLI credential to view remaining quota.", - "idle": "Not loaded. Click Refresh Button.", + "idle": "Click here to refresh quota", "loading": "Loading quota...", "load_failed": "Failed to load quota: {{message}}", "missing_auth_index": "Auth file missing auth_index", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ed769c9..d4b7c26 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -417,9 +417,6 @@ "proxy_url_placeholder": "socks5://username:password@proxy_ip:port/", "prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。", "prefix_proxy_saved_success": "已更新 \"{{name}}\"", - "card_tools_title": "配置面板", - "quota_refresh_single": "刷新额度", - "quota_refresh_hint": "仅刷新当前凭证的额度数据", "quota_refresh_success": "已刷新 \"{{name}}\" 的额度", "quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}" }, @@ -427,7 +424,7 @@ "title": "Antigravity 额度", "empty_title": "暂无 Antigravity 认证", "empty_desc": "上传 Antigravity 认证文件后即可查看额度。", - "idle": "尚未加载额度,请点击刷新按钮。", + "idle": "点击此处刷新额度", "loading": "正在加载额度...", "load_failed": "额度获取失败:{{message}}", "missing_auth_index": "认证文件缺少 auth_index", @@ -439,7 +436,7 @@ "title": "Codex 额度", "empty_title": "暂无 Codex 认证", "empty_desc": "上传 Codex 认证文件后即可查看额度。", - "idle": "尚未加载额度,请点击刷新按钮。", + "idle": "点击此处刷新额度", "loading": "正在加载额度...", "load_failed": "额度获取失败:{{message}}", "missing_auth_index": "认证文件缺少 auth_index", @@ -461,7 +458,7 @@ "title": "Gemini CLI 额度", "empty_title": "暂无 Gemini CLI 认证", "empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。", - "idle": "尚未加载额度,请点击刷新按钮。", + "idle": "点击此处刷新额度", "loading": "正在加载额度...", "load_failed": "额度获取失败:{{message}}", "missing_auth_index": "认证文件缺少 auth_index", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 471ae0c..69a8743 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -185,10 +185,10 @@ } .fileGridQuotaManaged { - grid-template-columns: repeat(auto-fill, minmax(520px, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); @include tablet { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } @include mobile { @@ -414,6 +414,24 @@ padding: $spacing-sm 0; } +.quotaMessageAction { + width: 100%; + border: none; + background: none; + cursor: pointer; + text-decoration: underline; + + &:hover:not(:disabled) { + color: var(--text-primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + text-decoration: none; + } +} + .quotaError { font-size: 12px; color: var(--danger-color); @@ -487,17 +505,6 @@ gap: $spacing-md; } -.fileCardLayoutQuota { - display: grid; - grid-template-columns: 1fr 156px; - gap: $spacing-md; - align-items: stretch; - - @include mobile { - grid-template-columns: 1fr; - } -} - .fileCardMain { display: flex; flex-direction: column; @@ -506,41 +513,6 @@ min-width: 0; } -.fileCardSidebar { - display: flex; - flex-direction: column; - gap: $spacing-sm; - padding-left: $spacing-md; - border-left: 1px dashed var(--border-color); - - @include mobile { - border-left: none; - border-top: 1px dashed var(--border-color); - padding-left: 0; - padding-top: $spacing-md; - } -} - -.fileCardSidebarHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-xs; -} - -.fileCardSidebarTitle { - font-size: 12px; - font-weight: 600; - color: var(--text-secondary); - white-space: nowrap; -} - -.fileCardSidebarHint { - font-size: 12px; - color: var(--text-tertiary); - line-height: 1.4; -} - .cardHeader { display: flex; align-items: center; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index f6cb03b..4fa9b72 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -17,7 +17,6 @@ import { IconChevronUp, IconDownload, IconInfo, - IconRefreshCw, IconTrash2, } from '@/components/ui/icons'; import type { TFunction } from 'i18next'; @@ -1547,6 +1546,7 @@ export function AuthFilesPage() { | { status?: string; error?: string; errorStatus?: number } | undefined; const quotaStatus = quota?.status ?? 'idle'; + const canRefreshQuota = !disableControls && !item.disabled; const quotaErrorMessage = resolveQuotaErrorMessage( t, quota?.errorStatus, @@ -1558,7 +1558,14 @@ export function AuthFilesPage() { {quotaStatus === 'loading' ? (
{t(`${config.i18nPrefix}.loading`)}
) : quotaStatus === 'idle' ? ( -
{t(`${config.i18nPrefix}.idle`)}
+ ) : quotaStatus === 'error' ? (
{t(`${config.i18nPrefix}.load_failed`, { @@ -1586,8 +1593,6 @@ export function AuthFilesPage() { quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null; const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly; - const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined; - const quotaRefreshing = quotaState?.status === 'loading'; const providerCardClass = quotaType === 'antigravity' @@ -1604,7 +1609,7 @@ export function AuthFilesPage() { className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`} >
@@ -1722,29 +1727,6 @@ export function AuthFilesPage() { )}
- - {showQuotaLayout && quotaType && ( -
-
- - {t('auth_files.card_tools_title')} - - -
-
{t('auth_files.quota_refresh_hint')}
-
- )}
); From 8acef95e5a75aa4824ff85e1e79dece6f32207d2 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 6 Feb 2026 22:43:50 +0800 Subject: [PATCH 10/24] add .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ffaaf5c..92a154d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ node_modules dist dist-ssr *.local +skills # Editor directories and files settings.local.json From 2da4099d0b9cd2e034c1ee6b4a1ec07bc69a3b2b Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 6 Feb 2026 23:35:47 +0800 Subject: [PATCH 11/24] feat(oauth): add kimi provider support --- src/i18n/locales/en.json | 13 +++++++++++++ src/i18n/locales/zh-CN.json | 13 +++++++++++++ src/pages/AuthFilesPage.tsx | 4 ++++ src/pages/OAuthPage.tsx | 3 +++ src/services/api/oauth.ts | 1 + src/types/authFile.ts | 1 + src/types/oauth.ts | 1 + src/utils/constants.ts | 2 ++ 8 files changed, 38 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8b3df64..befa3a8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -376,6 +376,7 @@ "filter_qwen": "Qwen", "filter_gemini": "Gemini", "filter_gemini-cli": "GeminiCLI", + "filter_kimi": "Kimi", "filter_aistudio": "AIStudio", "filter_claude": "Claude", "filter_codex": "Codex", @@ -387,6 +388,7 @@ "type_qwen": "Qwen", "type_gemini": "Gemini", "type_gemini-cli": "GeminiCLI", + "type_kimi": "Kimi", "type_aistudio": "AIStudio", "type_claude": "Claude", "type_codex": "Codex", @@ -637,6 +639,17 @@ "gemini_cli_oauth_status_error": "Authentication failed:", "gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:", "gemini_cli_oauth_polling_error": "Failed to check authentication status:", + "kimi_oauth_title": "Kimi OAuth", + "kimi_oauth_button": "Start Kimi Login", + "kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.", + "kimi_oauth_url_label": "Authorization URL:", + "kimi_open_link": "Open Link", + "kimi_copy_link": "Copy Link", + "kimi_oauth_status_waiting": "Waiting for authentication...", + "kimi_oauth_status_success": "Authentication successful!", + "kimi_oauth_status_error": "Authentication failed:", + "kimi_oauth_start_error": "Failed to start Kimi OAuth:", + "kimi_oauth_polling_error": "Failed to check authentication status:", "qwen_oauth_title": "Qwen OAuth", "qwen_oauth_button": "Start Qwen Login", "qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d4b7c26..f04e6bc 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -376,6 +376,7 @@ "filter_qwen": "Qwen", "filter_gemini": "Gemini", "filter_gemini-cli": "GeminiCLI", + "filter_kimi": "Kimi", "filter_aistudio": "AIStudio", "filter_claude": "Claude", "filter_codex": "Codex", @@ -387,6 +388,7 @@ "type_qwen": "Qwen", "type_gemini": "Gemini", "type_gemini-cli": "GeminiCLI", + "type_kimi": "Kimi", "type_aistudio": "AIStudio", "type_claude": "Claude", "type_codex": "Codex", @@ -637,6 +639,17 @@ "gemini_cli_oauth_status_error": "认证失败:", "gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:", "gemini_cli_oauth_polling_error": "检查认证状态失败:", + "kimi_oauth_title": "Kimi OAuth", + "kimi_oauth_button": "开始 Kimi 登录", + "kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。", + "kimi_oauth_url_label": "授权链接:", + "kimi_open_link": "打开链接", + "kimi_copy_link": "复制链接", + "kimi_oauth_status_waiting": "等待认证中...", + "kimi_oauth_status_success": "认证成功!", + "kimi_oauth_status_error": "认证失败:", + "kimi_oauth_start_error": "启动 Kimi OAuth 失败:", + "kimi_oauth_polling_error": "检查认证状态失败:", "qwen_oauth_title": "Qwen OAuth", "qwen_oauth_button": "开始 Qwen 登录", "qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。", diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 4fa9b72..6b239d6 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -48,6 +48,10 @@ const TYPE_COLORS: Record = { light: { bg: '#e8f5e9', text: '#2e7d32' }, dark: { bg: '#1b5e20', text: '#81c784' }, }, + kimi: { + light: { bg: '#fff4e5', text: '#ad6800' }, + dark: { bg: '#7c4a03', text: '#ffd591' }, + }, gemini: { light: { bg: '#e3f2fd', text: '#1565c0' }, dark: { bg: '#0d47a1', text: '#64b5f6' }, diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index 066a4b6..f0bc72d 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -12,6 +12,8 @@ import iconCodexDark from '@/assets/icons/codex_drak.svg'; import iconClaude from '@/assets/icons/claude.svg'; import iconAntigravity from '@/assets/icons/antigravity.svg'; import iconGemini from '@/assets/icons/gemini.svg'; +import iconKimiLight from '@/assets/icons/kimi-light.svg'; +import iconKimiDark from '@/assets/icons/kimi-dark.svg'; import iconQwen from '@/assets/icons/qwen.svg'; import iconIflow from '@/assets/icons/iflow.svg'; import iconVertex from '@/assets/icons/vertex.svg'; @@ -59,6 +61,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity }, { id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini }, + { id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } }, { id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen } ]; diff --git a/src/services/api/oauth.ts b/src/services/api/oauth.ts index 456aafd..231d8d7 100644 --- a/src/services/api/oauth.ts +++ b/src/services/api/oauth.ts @@ -9,6 +9,7 @@ export type OAuthProvider = | 'anthropic' | 'antigravity' | 'gemini-cli' + | 'kimi' | 'qwen'; export interface OAuthStartResponse { diff --git a/src/types/authFile.ts b/src/types/authFile.ts index 86b71f4..2a56284 100644 --- a/src/types/authFile.ts +++ b/src/types/authFile.ts @@ -5,6 +5,7 @@ export type AuthFileType = | 'qwen' + | 'kimi' | 'gemini' | 'gemini-cli' | 'aistudio' diff --git a/src/types/oauth.ts b/src/types/oauth.ts index 2a32cde..524eb13 100644 --- a/src/types/oauth.ts +++ b/src/types/oauth.ts @@ -9,6 +9,7 @@ export type OAuthProvider = | 'anthropic' | 'antigravity' | 'gemini-cli' + | 'kimi' | 'qwen'; // OAuth 流程状态 diff --git a/src/utils/constants.ts b/src/utils/constants.ts index eb1b650..8266a8d 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -42,6 +42,7 @@ export const OAUTH_CARD_IDS = [ 'anthropic-oauth-card', 'antigravity-oauth-card', 'gemini-cli-oauth-card', + 'kimi-oauth-card', 'qwen-oauth-card' ]; export const OAUTH_PROVIDERS = { @@ -49,6 +50,7 @@ export const OAUTH_PROVIDERS = { ANTHROPIC: 'anthropic', ANTIGRAVITY: 'antigravity', GEMINI_CLI: 'gemini-cli', + KIMI: 'kimi', QWEN: 'qwen' } as const; From 700bff1d03b93737229baf6e6c5f686e6f8b04e1 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 00:43:36 +0800 Subject: [PATCH 12/24] fix(i18n): harden language switching and enforce language list consistency --- src/components/layout/MainLayout.tsx | 8 ++++++-- src/pages/LoginPage.tsx | 9 +++++++-- src/stores/useLanguageStore.ts | 20 +++++++++++++++++--- src/utils/constants.ts | 8 ++++++-- src/utils/language.ts | 11 +++++++---- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index d4802bb..233150c 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -36,7 +36,7 @@ import { import { configApi, versionApi } from '@/services/api'; import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants'; -import type { Language } from '@/types'; +import { isSupportedLanguage } from '@/utils/language'; const sidebarIcons: Record = { dashboard: , @@ -196,7 +196,11 @@ export function MainLayout() { const handleLanguageChange = useCallback( (event: React.ChangeEvent) => { - setLanguage(event.target.value as Language); + const selectedLanguage = event.target.value; + if (!isSupportedLanguage(selectedLanguage)) { + return; + } + setLanguage(selectedLanguage); }, [setLanguage] ); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 829bfe6..8e8daf1 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -7,8 +7,9 @@ import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants'; +import { isSupportedLanguage } from '@/utils/language'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; -import type { ApiError, Language } from '@/types'; +import type { ApiError } from '@/types'; import styles from './LoginPage.module.scss'; /** @@ -81,7 +82,11 @@ export function LoginPage() { const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); const handleLanguageChange = useCallback( (event: React.ChangeEvent) => { - setLanguage(event.target.value as Language); + const selectedLanguage = event.target.value; + if (!isSupportedLanguage(selectedLanguage)) { + return; + } + setLanguage(selectedLanguage); }, [setLanguage] ); diff --git a/src/stores/useLanguageStore.ts b/src/stores/useLanguageStore.ts index e80183e..7b6eb23 100644 --- a/src/stores/useLanguageStore.ts +++ b/src/stores/useLanguageStore.ts @@ -8,11 +8,11 @@ import { persist } from 'zustand/middleware'; import type { Language } from '@/types'; import { LANGUAGE_ORDER, STORAGE_KEY_LANGUAGE } from '@/utils/constants'; import i18n from '@/i18n'; -import { getInitialLanguage } from '@/utils/language'; +import { getInitialLanguage, isSupportedLanguage } from '@/utils/language'; interface LanguageState { language: Language; - setLanguage: (language: Language) => void; + setLanguage: (language: string) => void; toggleLanguage: () => void; } @@ -22,6 +22,9 @@ export const useLanguageStore = create()( language: getInitialLanguage(), setLanguage: (language) => { + if (!isSupportedLanguage(language)) { + return; + } // 切换 i18next 语言 i18n.changeLanguage(language); set({ language }); @@ -35,7 +38,18 @@ export const useLanguageStore = create()( } }), { - name: STORAGE_KEY_LANGUAGE + name: STORAGE_KEY_LANGUAGE, + merge: (persistedState, currentState) => { + const nextLanguage = (persistedState as Partial)?.language; + if (typeof nextLanguage === 'string' && isSupportedLanguage(nextLanguage)) { + return { + ...currentState, + ...(persistedState as Partial), + language: nextLanguage + }; + } + return currentState; + } } ) ); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a15163d..e3b36c5 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -5,6 +5,10 @@ import type { Language } from '@/types'; +const defineLanguageOrder = ( + languages: T & ([Language] extends [T[number]] ? unknown : never) +) => languages; + // 缓存过期时间(毫秒) export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力 @@ -36,13 +40,13 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed'; export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size'; // 语言配置 -export const LANGUAGE_ORDER: Language[] = ['zh-CN', 'en', 'ru']; +export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const); export const LANGUAGE_LABEL_KEYS: Record = { 'zh-CN': 'language.chinese', en: 'language.english', ru: 'language.russian' }; -export const SUPPORTED_LANGUAGES: Language[] = LANGUAGE_ORDER; +export const SUPPORTED_LANGUAGES = LANGUAGE_ORDER; // 通知持续时间 export const NOTIFICATION_DURATION_MS = 3000; diff --git a/src/utils/language.ts b/src/utils/language.ts index 8aa1767..ea8b2cc 100644 --- a/src/utils/language.ts +++ b/src/utils/language.ts @@ -1,16 +1,19 @@ import type { Language } from '@/types'; import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants'; +export const isSupportedLanguage = (value: string): value is Language => + SUPPORTED_LANGUAGES.includes(value as Language); + const parseStoredLanguage = (value: string): Language | null => { try { const parsed = JSON.parse(value); const candidate = parsed?.state?.language ?? parsed?.language ?? parsed; - if (SUPPORTED_LANGUAGES.includes(candidate as Language)) { - return candidate as Language; + if (typeof candidate === 'string' && isSupportedLanguage(candidate)) { + return candidate; } } catch { - if (SUPPORTED_LANGUAGES.includes(value as Language)) { - return value as Language; + if (isSupportedLanguage(value)) { + return value; } } return null; From 385117d01a0b3609e24274c17fcdf0e7d3c24644 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 01:13:11 +0800 Subject: [PATCH 13/24] fix(i18n): switch language via popover menu and complete Russian Kimi translations --- src/components/layout/MainLayout.tsx | 95 ++++++++++++++++++++-------- src/i18n/locales/ru.json | 13 ++++ src/styles/layout.scss | 76 ++++++++++++++-------- 3 files changed, 134 insertions(+), 50 deletions(-) diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 233150c..6e8aaa0 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -194,26 +194,17 @@ export function MainLayout() { const language = useLanguageStore((state) => state.language); const setLanguage = useLanguageStore((state) => state.setLanguage); - const handleLanguageChange = useCallback( - (event: React.ChangeEvent) => { - const selectedLanguage = event.target.value; - if (!isSupportedLanguage(selectedLanguage)) { - return; - } - setLanguage(selectedLanguage); - }, - [setLanguage] - ); - const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [checkingVersion, setCheckingVersion] = useState(false); + const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [brandExpanded, setBrandExpanded] = useState(true); const [requestLogModalOpen, setRequestLogModalOpen] = useState(false); const [requestLogDraft, setRequestLogDraft] = useState(false); const [requestLogTouched, setRequestLogTouched] = useState(false); const [requestLogSaving, setRequestLogSaving] = useState(false); const contentRef = useRef(null); + const languageMenuRef = useRef(null); const brandCollapseTimer = useRef | null>(null); const headerRef = useRef(null); const versionTapCount = useRef(0); @@ -313,6 +304,32 @@ export function MainLayout() { }; }, []); + useEffect(() => { + if (!languageMenuOpen) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + if (!languageMenuRef.current?.contains(event.target as Node)) { + setLanguageMenuOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setLanguageMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [languageMenuOpen]); + const handleBrandClick = useCallback(() => { if (!brandExpanded) { setBrandExpanded(true); @@ -332,6 +349,21 @@ export function MainLayout() { setRequestLogModalOpen(true); }, [requestLogEnabled]); + const toggleLanguageMenu = useCallback(() => { + setLanguageMenuOpen((prev) => !prev); + }, []); + + const handleLanguageSelect = useCallback( + (nextLanguage: string) => { + if (!isSupportedLanguage(nextLanguage)) { + return; + } + setLanguage(nextLanguage); + setLanguageMenuOpen(false); + }, + [setLanguage] + ); + const handleRequestLogClose = useCallback(() => { setRequestLogModalOpen(false); setRequestLogTouched(false); @@ -580,22 +612,35 @@ export function MainLayout() { > {headerIcons.update} -
- - + {headerIcons.language} + + {languageMenuOpen && ( +
+ {LANGUAGE_ORDER.map((lang) => ( + + ))} +
+ )}
From 0b54b6de64f1020971618dc1c523cb406c66c1f3 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 10:57:52 +0800 Subject: [PATCH 15/24] fix(auth-files): add Kimi to OAuth quick-fill provider tags --- src/pages/AuthFilesOAuthExcludedEditPage.tsx | 1 + src/pages/AuthFilesOAuthModelAliasEditPage.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/pages/AuthFilesOAuthExcludedEditPage.tsx b/src/pages/AuthFilesOAuthExcludedEditPage.tsx index 1fe8985..c6e7154 100644 --- a/src/pages/AuthFilesOAuthExcludedEditPage.tsx +++ b/src/pages/AuthFilesOAuthExcludedEditPage.tsx @@ -26,6 +26,7 @@ const OAUTH_PROVIDER_PRESETS = [ 'claude', 'codex', 'qwen', + 'kimi', 'iflow', ]; diff --git a/src/pages/AuthFilesOAuthModelAliasEditPage.tsx b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx index 35e617c..1e81dad 100644 --- a/src/pages/AuthFilesOAuthModelAliasEditPage.tsx +++ b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx @@ -29,6 +29,7 @@ const OAUTH_PROVIDER_PRESETS = [ 'claude', 'codex', 'qwen', + 'kimi', 'iflow', ]; From e053854544ee96d2c405e2daec0b1fc7594149d9 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 12:03:40 +0800 Subject: [PATCH 16/24] feat(system): redesign system info page and move request-log controls from layout footer --- src/components/layout/MainLayout.tsx | 137 +------------------- src/i18n/locales/en.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/pages/SystemPage.module.scss | 128 +++++++++++++++++++ src/pages/SystemPage.tsx | 181 +++++++++++++++++++++++---- src/styles/layout.scss | 21 ---- 7 files changed, 292 insertions(+), 178 deletions(-) diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 6e8aaa0..074d9ea 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -10,8 +10,6 @@ import { import { NavLink, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; -import { Modal } from '@/components/ui/Modal'; -import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { PageTransition } from '@/components/common/PageTransition'; import { MainRoutes } from '@/router/MainRoutes'; import { @@ -33,7 +31,7 @@ import { useNotificationStore, useThemeStore, } from '@/stores'; -import { configApi, versionApi } from '@/services/api'; +import { versionApi } from '@/services/api'; import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants'; import { isSupportedLanguage } from '@/utils/language'; @@ -174,20 +172,18 @@ const compareVersions = (latest?: string | null, current?: string | null) => { }; export function MainLayout() { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const location = useLocation(); const apiBase = useAuthStore((state) => state.apiBase); const serverVersion = useAuthStore((state) => state.serverVersion); - const serverBuildDate = useAuthStore((state) => state.serverBuildDate); const connectionStatus = useAuthStore((state) => state.connectionStatus); const logout = useAuthStore((state) => state.logout); const config = useConfigStore((state) => state.config); const fetchConfig = useConfigStore((state) => state.fetchConfig); const clearCache = useConfigStore((state) => state.clearCache); - const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const theme = useThemeStore((state) => state.theme); const cycleTheme = useThemeStore((state) => state.cycleTheme); @@ -199,22 +195,13 @@ export function MainLayout() { const [checkingVersion, setCheckingVersion] = useState(false); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [brandExpanded, setBrandExpanded] = useState(true); - const [requestLogModalOpen, setRequestLogModalOpen] = useState(false); - const [requestLogDraft, setRequestLogDraft] = useState(false); - const [requestLogTouched, setRequestLogTouched] = useState(false); - const [requestLogSaving, setRequestLogSaving] = useState(false); const contentRef = useRef(null); const languageMenuRef = useRef(null); const brandCollapseTimer = useRef | null>(null); const headerRef = useRef(null); - const versionTapCount = useRef(0); - const versionTapTimer = useRef | null>(null); const fullBrandName = 'CLI Proxy API Management Center'; const abbrBrandName = t('title.abbr'); - const requestLogEnabled = config?.requestLog ?? false; - const requestLogDirty = requestLogDraft !== requestLogEnabled; - const canEditRequestLog = connectionStatus === 'connected' && Boolean(config); const isLogsPage = location.pathname.startsWith('/logs'); // 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动 @@ -246,7 +233,7 @@ export function MainLayout() { }; }, []); - // 将主内容区的中心点写入 CSS 变量,供底部浮层(如配置面板操作栏)对齐到内容区而非整窗 + // 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区 useLayoutEffect(() => { const updateContentCenter = () => { const el = contentRef.current; @@ -274,6 +261,7 @@ export function MainLayout() { resizeObserver.disconnect(); } window.removeEventListener('resize', updateContentCenter); + document.documentElement.style.removeProperty('--content-center-x'); }; }, []); @@ -290,20 +278,6 @@ export function MainLayout() { }; }, []); - useEffect(() => { - if (requestLogModalOpen && !requestLogTouched) { - setRequestLogDraft(requestLogEnabled); - } - }, [requestLogModalOpen, requestLogTouched, requestLogEnabled]); - - useEffect(() => { - return () => { - if (versionTapTimer.current) { - clearTimeout(versionTapTimer.current); - } - }; - }, []); - useEffect(() => { if (!languageMenuOpen) { return; @@ -343,12 +317,6 @@ export function MainLayout() { } }, [brandExpanded]); - const openRequestLogModal = useCallback(() => { - setRequestLogTouched(false); - setRequestLogDraft(requestLogEnabled); - setRequestLogModalOpen(true); - }, [requestLogEnabled]); - const toggleLanguageMenu = useCallback(() => { setLanguageMenuOpen((prev) => !prev); }, []); @@ -364,54 +332,6 @@ export function MainLayout() { [setLanguage] ); - const handleRequestLogClose = useCallback(() => { - setRequestLogModalOpen(false); - setRequestLogTouched(false); - }, []); - - const handleVersionTap = useCallback(() => { - versionTapCount.current += 1; - if (versionTapTimer.current) { - clearTimeout(versionTapTimer.current); - } - versionTapTimer.current = setTimeout(() => { - versionTapCount.current = 0; - }, 1500); - - if (versionTapCount.current >= 7) { - versionTapCount.current = 0; - if (versionTapTimer.current) { - clearTimeout(versionTapTimer.current); - versionTapTimer.current = null; - } - openRequestLogModal(); - } - }, [openRequestLogModal]); - - const handleRequestLogSave = async () => { - if (!canEditRequestLog) return; - if (!requestLogDirty) { - setRequestLogModalOpen(false); - return; - } - - const previous = requestLogEnabled; - setRequestLogSaving(true); - updateConfigValue('request-log', requestLogDraft); - - try { - await configApi.updateRequestLog(requestLogDraft); - clearCache('request-log'); - showNotification(t('notification.request_log_updated'), 'success'); - setRequestLogModalOpen(false); - } catch (error: any) { - updateConfigValue('request-log', previous); - showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error'); - } finally { - setRequestLogSaving(false); - } - }; - useEffect(() => { fetchConfig().catch(() => { // ignore initial failure; login flow会提示 @@ -685,57 +605,8 @@ export function MainLayout() { scrollContainerRef={contentRef} /> - -
- - {t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} - - - {t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')} - - - {t('footer.build_date')}:{' '} - {serverBuildDate - ? new Date(serverBuildDate).toLocaleString(i18n.language) - : t('system_info.version_unknown')} - -
- - - - - - } - > -
-
{t('basic_settings.request_log_warning')}
- { - setRequestLogDraft(value); - setRequestLogTouched(true); - }} - /> -
-
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b07a09a..e6a2276 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -989,6 +989,7 @@ }, "system_info": { "title": "Management Center Info", + "about_title": "CLI Proxy API Management Center", "connection_status_title": "Connection Status", "api_status_label": "API Status:", "config_status_label": "Config Status:", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2bc9eaa..d30a9eb 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -994,6 +994,7 @@ }, "system_info": { "title": "Информация о центре управления", + "about_title": "CLI Proxy API Management Center", "connection_status_title": "Статус подключения", "api_status_label": "Статус API:", "config_status_label": "Статус конфигурации:", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6ea4000..8e00018 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -989,6 +989,7 @@ }, "system_info": { "title": "管理中心信息", + "about_title": "CLI Proxy API Management Center", "connection_status_title": "连接状态", "api_status_label": "API 状态:", "config_status_label": "配置状态:", diff --git a/src/pages/SystemPage.module.scss b/src/pages/SystemPage.module.scss index 459b741..dbef6f1 100644 --- a/src/pages/SystemPage.module.scss +++ b/src/pages/SystemPage.module.scss @@ -15,6 +15,108 @@ gap: $spacing-xl; } +.aboutCard { + overflow: hidden; +} + +.aboutHeader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + gap: $spacing-md; + padding: $spacing-lg 0 $spacing-xl; +} + +.aboutLogo { + width: 108px; + height: 108px; + border-radius: 26px; + object-fit: cover; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); +} + +.aboutTitle { + width: min(100%, 920px); + font-size: clamp(28px, 4.2vw, 44px); + font-weight: 800; + line-height: 1.12; + color: var(--text-primary); + letter-spacing: -0.02em; + text-align: center; + text-wrap: balance; + white-space: normal; + overflow-wrap: anywhere; +} + +.aboutInfoGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $spacing-md; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } +} + +.infoTile { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 120px; + padding: $spacing-md $spacing-lg; + border-radius: $radius-lg; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + text-align: left; +} + +.tapTile { + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + color: inherit; + padding: $spacing-md $spacing-lg; + cursor: pointer; + transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + border-color: var(--primary-color); + box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15); + } + + &:active { + transform: translateY(0); + } +} + +.tileLabel { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.tileValue { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.25; + word-break: break-word; +} + +.tileSub { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + +.aboutActions { + display: flex; + justify-content: flex-end; + margin-top: $spacing-lg; +} + .section { display: flex; flex-direction: column; @@ -231,3 +333,29 @@ overflow: hidden; text-overflow: ellipsis; } + +@media (max-width: 768px) { + .aboutLogo { + width: 92px; + height: 92px; + border-radius: 22px; + } + + .aboutTitle { + width: min(100%, 24ch); + font-size: clamp(22px, 6.6vw, 34px); + font-weight: 700; + line-height: 1.18; + letter-spacing: -0.012em; + } +} + +@media (max-width: 520px) { + .aboutTitle { + width: min(100%, 19ch); + font-size: clamp(20px, 7.2vw, 28px); + font-weight: 600; + line-height: 1.22; + letter-spacing: -0.006em; + } +} diff --git a/src/pages/SystemPage.tsx b/src/pages/SystemPage.tsx index d428aeb..e4f3069 100644 --- a/src/pages/SystemPage.tsx +++ b/src/pages/SystemPage.tsx @@ -2,11 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons'; import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores'; +import { configApi } from '@/services/api'; import { apiKeysApi } from '@/services/api/apiKeys'; import { classifyModels } from '@/utils/models'; import { STORAGE_KEY_AUTH } from '@/utils/constants'; +import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import iconGemini from '@/assets/icons/gemini.svg'; import iconClaude from '@/assets/icons/claude.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; @@ -39,6 +43,8 @@ export function SystemPage() { const auth = useAuthStore(); const config = useConfigStore((state) => state.config); const fetchConfig = useConfigStore((state) => state.fetchConfig); + const clearCache = useConfigStore((state) => state.clearCache); + const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const models = useModelsStore((state) => state.models); const modelsLoading = useModelsStore((state) => state.loading); @@ -46,14 +52,29 @@ export function SystemPage() { const fetchModelsFromStore = useModelsStore((state) => state.fetchModels); const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>(); + const [requestLogModalOpen, setRequestLogModalOpen] = useState(false); + const [requestLogDraft, setRequestLogDraft] = useState(false); + const [requestLogTouched, setRequestLogTouched] = useState(false); + const [requestLogSaving, setRequestLogSaving] = useState(false); const apiKeysCache = useRef([]); + const versionTapCount = useRef(0); + const versionTapTimer = useRef | null>(null); const otherLabel = useMemo( () => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'), [i18n.language] ); const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]); + const requestLogEnabled = config?.requestLog ?? false; + const requestLogDirty = requestLogDraft !== requestLogEnabled; + const canEditRequestLog = auth.connectionStatus === 'connected' && Boolean(config); + + const appVersion = __APP_VERSION__ || t('system_info.version_unknown'); + const apiVersion = auth.serverVersion || t('system_info.version_unknown'); + const buildTime = auth.serverBuildDate + ? new Date(auth.serverBuildDate).toLocaleString(i18n.language) + : t('system_info.version_unknown'); const getIconForCategory = (categoryId: string): string | null => { const iconEntry = MODEL_CATEGORY_ICONS[categoryId]; @@ -152,12 +173,80 @@ export function SystemPage() { }); }; + const openRequestLogModal = useCallback(() => { + setRequestLogTouched(false); + setRequestLogDraft(requestLogEnabled); + setRequestLogModalOpen(true); + }, [requestLogEnabled]); + + const handleInfoVersionTap = useCallback(() => { + versionTapCount.current += 1; + if (versionTapTimer.current) { + clearTimeout(versionTapTimer.current); + } + + if (versionTapCount.current >= 7) { + versionTapCount.current = 0; + versionTapTimer.current = null; + openRequestLogModal(); + return; + } + + versionTapTimer.current = setTimeout(() => { + versionTapCount.current = 0; + versionTapTimer.current = null; + }, 1500); + }, [openRequestLogModal]); + + const handleRequestLogClose = useCallback(() => { + setRequestLogModalOpen(false); + setRequestLogTouched(false); + }, []); + + const handleRequestLogSave = async () => { + if (!canEditRequestLog) return; + if (!requestLogDirty) { + setRequestLogModalOpen(false); + return; + } + + const previous = requestLogEnabled; + setRequestLogSaving(true); + updateConfigValue('request-log', requestLogDraft); + + try { + await configApi.updateRequestLog(requestLogDraft); + clearCache('request-log'); + showNotification(t('notification.request_log_updated'), 'success'); + setRequestLogModalOpen(false); + } catch (error: any) { + updateConfigValue('request-log', previous); + showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error'); + } finally { + setRequestLogSaving(false); + } + }; + useEffect(() => { fetchConfig().catch(() => { // ignore }); }, [fetchConfig]); + useEffect(() => { + if (requestLogModalOpen && !requestLogTouched) { + setRequestLogDraft(requestLogEnabled); + } + }, [requestLogModalOpen, requestLogTouched, requestLogEnabled]); + + useEffect(() => { + return () => { + if (versionTapTimer.current) { + clearTimeout(versionTapTimer.current); + } + }; + }, []); + useEffect(() => { fetchModels(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -167,33 +256,43 @@ export function SystemPage() {

{t('system_info.title')}

- +
+ CPAMC +
{t('system_info.about_title')}
+
+ +
+ + +
+
{t('footer.api_version')}
+
{apiVersion}
+
+ +
+
{t('footer.build_date')}
+
{buildTime}
+
+ +
+
{t('connection.status')}
+
{t(`common.${auth.connectionStatus}_status` as any)}
+
{auth.apiBase || '-'}
+
+
+ +
- } - > -
-
-
{t('connection.server_address')}
-
{auth.apiBase || '-'}
-
-
-
{t('footer.api_version')}
-
{auth.serverVersion || t('system_info.version_unknown')}
-
-
-
{t('footer.build_date')}
-
- {auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')} -
-
-
-
{t('connection.status')}
-
{t(`common.${auth.connectionStatus}_status` as any)}
-
@@ -312,6 +411,40 @@ export function SystemPage() {
+ + + + + + } + > +
+
{t('basic_settings.request_log_warning')}
+ { + setRequestLogDraft(value); + setRequestLogTouched(true); + }} + /> +
+
); } diff --git a/src/styles/layout.scss b/src/styles/layout.scss index b6d041e..83f102b 100644 --- a/src/styles/layout.scss +++ b/src/styles/layout.scss @@ -448,27 +448,6 @@ } } -.footer { - padding: $spacing-md $spacing-lg; - border-top: 1px solid var(--border-color); - background: var(--bg-primary); - display: flex; - justify-content: space-between; - align-items: center; - color: var(--text-secondary); - font-size: 14px; - flex-wrap: wrap; - gap: $spacing-sm; - - .footer-version { - user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - -webkit-touch-callout: none; - } -} - - .grid { display: grid; gap: $spacing-lg; From 525b152a7601f5a2c8c7b9e98abeb44b53227929 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 12:22:16 +0800 Subject: [PATCH 17/24] fix(config): preserve mobile scroll after API key modal close and add one-click key copy --- src/components/config/VisualConfigEditor.tsx | 33 +++++++++ src/components/ui/Modal.tsx | 73 ++++++++++++++++++-- src/i18n/locales/en.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + 5 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index 7691045..7f41220 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -6,6 +6,7 @@ import { Modal } from '@/components/ui/Modal'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconChevronDown } from '@/components/ui/icons'; import { ConfigSection } from '@/components/config/ConfigSection'; +import { useNotificationStore } from '@/stores'; import styles from './VisualConfigEditor.module.scss'; import type { PayloadFilterRule, @@ -201,6 +202,7 @@ function ApiKeysCardEditor({ onChange: (nextValue: string) => void; }) { const { t } = useTranslation(); + const { showNotification } = useNotificationStore(); const apiKeys = useMemo( () => value @@ -263,6 +265,34 @@ function ApiKeysCardEditor({ closeModal(); }; + const handleCopy = async (apiKey: string) => { + const copyByExecCommand = () => { + const textarea = document.createElement('textarea'); + textarea.value = apiKey; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!copied) throw new Error('copy_failed'); + }; + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(apiKey); + } else { + copyByExecCommand(); + } + showNotification(t('notification.link_copied'), 'success'); + } catch { + showNotification(t('notification.copy_failed'), 'error'); + } + }; + return (
@@ -294,6 +324,9 @@ function ApiKeysCardEditor({
{maskApiKey(String(key || ''))}
+ diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 45eff25..03790b5 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -16,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350; const MODAL_LOCK_CLASS = 'modal-open'; let activeModalCount = 0; +const scrollLockSnapshot = { + scrollY: 0, + contentScrollTop: 0, + contentEl: null as HTMLElement | null, + bodyPosition: '', + bodyTop: '', + bodyLeft: '', + bodyRight: '', + bodyWidth: '', + bodyOverflow: '', + htmlOverflow: '', +}; + +const resolveContentScrollContainer = () => { + if (typeof document === 'undefined') return null; + const contentEl = document.querySelector('.content'); + return contentEl instanceof HTMLElement ? contentEl : null; +}; + const lockScroll = () => { if (typeof document === 'undefined') return; if (activeModalCount === 0) { - document.body?.classList.add(MODAL_LOCK_CLASS); - document.documentElement?.classList.add(MODAL_LOCK_CLASS); + const body = document.body; + const html = document.documentElement; + const contentEl = resolveContentScrollContainer(); + + scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0; + scrollLockSnapshot.contentEl = contentEl; + scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0; + scrollLockSnapshot.bodyPosition = body.style.position; + scrollLockSnapshot.bodyTop = body.style.top; + scrollLockSnapshot.bodyLeft = body.style.left; + scrollLockSnapshot.bodyRight = body.style.right; + scrollLockSnapshot.bodyWidth = body.style.width; + scrollLockSnapshot.bodyOverflow = body.style.overflow; + scrollLockSnapshot.htmlOverflow = html.style.overflow; + + body.classList.add(MODAL_LOCK_CLASS); + html.classList.add(MODAL_LOCK_CLASS); + + body.style.position = 'fixed'; + body.style.top = `-${scrollLockSnapshot.scrollY}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.width = '100%'; + body.style.overflow = 'hidden'; + html.style.overflow = 'hidden'; } activeModalCount += 1; }; @@ -29,8 +71,31 @@ const unlockScroll = () => { if (typeof document === 'undefined') return; activeModalCount = Math.max(0, activeModalCount - 1); if (activeModalCount === 0) { - document.body?.classList.remove(MODAL_LOCK_CLASS); - document.documentElement?.classList.remove(MODAL_LOCK_CLASS); + const body = document.body; + const html = document.documentElement; + const scrollY = scrollLockSnapshot.scrollY; + const contentScrollTop = scrollLockSnapshot.contentScrollTop; + const contentEl = scrollLockSnapshot.contentEl; + + body.classList.remove(MODAL_LOCK_CLASS); + html.classList.remove(MODAL_LOCK_CLASS); + + body.style.position = scrollLockSnapshot.bodyPosition; + body.style.top = scrollLockSnapshot.bodyTop; + body.style.left = scrollLockSnapshot.bodyLeft; + body.style.right = scrollLockSnapshot.bodyRight; + body.style.width = scrollLockSnapshot.bodyWidth; + body.style.overflow = scrollLockSnapshot.bodyOverflow; + html.style.overflow = scrollLockSnapshot.htmlOverflow; + + if (contentEl) { + contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' }); + } + window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' }); + + scrollLockSnapshot.scrollY = 0; + scrollLockSnapshot.contentScrollTop = 0; + scrollLockSnapshot.contentEl = null; } }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e6a2276..e5d4001 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1094,6 +1094,7 @@ "gemini_api_key": "Gemini API key", "codex_api_key": "Codex API key", "claude_api_key": "Claude API key", + "copy_failed": "Copy failed", "link_copied": "Link copied to clipboard" }, "language": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d30a9eb..80c0b9f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1099,6 +1099,7 @@ "gemini_api_key": "API-ключ Gemini", "codex_api_key": "API-ключ Codex", "claude_api_key": "API-ключ Claude", + "copy_failed": "Не удалось скопировать", "link_copied": "Ссылка скопирована в буфер обмена" }, "language": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8e00018..014f25d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1094,6 +1094,7 @@ "gemini_api_key": "Gemini API密钥", "codex_api_key": "Codex API密钥", "claude_api_key": "Claude API密钥", + "copy_failed": "复制失败", "link_copied": "已复制" }, "language": { From 709ce4c8dd9bc1e0e39f5a24e23ef14bc6a9e607 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 12:31:17 +0800 Subject: [PATCH 18/24] feat(config): warn restart required when commercial mode changes --- src/i18n/locales/en.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/pages/ConfigPage.tsx | 17 +++++++++++++++++ 4 files changed, 20 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e5d4001..668d118 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1094,6 +1094,7 @@ "gemini_api_key": "Gemini API key", "codex_api_key": "Codex API key", "claude_api_key": "Claude API key", + "commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect", "copy_failed": "Copy failed", "link_copied": "Link copied to clipboard" }, diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 80c0b9f..7b62437 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1099,6 +1099,7 @@ "gemini_api_key": "API-ключ Gemini", "codex_api_key": "API-ключ Codex", "claude_api_key": "API-ключ Claude", + "commercial_mode_restart_required": "Режим коммерческого использования изменён. Перезапустите сервис, чтобы применить изменения", "copy_failed": "Не удалось скопировать", "link_copied": "Ссылка скопирована в буфер обмена" }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 014f25d..6bf2332 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1094,6 +1094,7 @@ "gemini_api_key": "Gemini API密钥", "codex_api_key": "Codex API密钥", "claude_api_key": "Claude API密钥", + "commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效", "copy_failed": "复制失败", "link_copied": "已复制" }, diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index eaf9442..0191c31 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -5,6 +5,7 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import { yaml } from '@codemirror/lang-yaml'; import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { keymap } from '@codemirror/view'; +import { parse as parseYaml } from 'yaml'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -17,6 +18,16 @@ import styles from './ConfigPage.module.scss'; type ConfigEditorTab = 'visual' | 'source'; +function readCommercialModeFromYaml(yamlContent: string): boolean { + try { + const parsed = parseYaml(yamlContent); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; + return Boolean((parsed as Record)['commercial-mode']); + } catch { + return false; + } +} + export function ConfigPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); @@ -78,13 +89,19 @@ export function ConfigPage() { const handleSave = async () => { setSaving(true); try { + const previousCommercialMode = readCommercialModeFromYaml(content); const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content; + const nextCommercialMode = readCommercialModeFromYaml(nextContent); + const commercialModeChanged = previousCommercialMode !== nextCommercialMode; await configFileApi.saveConfigYaml(nextContent); const latestContent = await configFileApi.fetchConfigYaml(); setDirty(false); setContent(latestContent); loadVisualValuesFromYaml(latestContent); showNotification(t('config_management.save_success'), 'success'); + if (commercialModeChanged) { + showNotification(t('notification.commercial_mode_restart_required'), 'warning'); + } } catch (err: unknown) { const message = err instanceof Error ? err.message : ''; showNotification(`${t('notification.save_failed')}: ${message}`, 'error'); From 36bfd0fa6aa0bbe2fb1a81f18f5337b1ad236e1d Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 12:43:56 +0800 Subject: [PATCH 19/24] chore(i18n): align en/ru OAuth disablement wording with updated zh-CN copy --- src/i18n/locales/en.json | 48 ++++++++++++++++++++-------------------- src/i18n/locales/ru.json | 48 ++++++++++++++++++++-------------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 668d118..a98ff94 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -405,8 +405,8 @@ "models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.", "models_unsupported": "This feature is not supported in the current version", "models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again", - "models_excluded_badge": "Excluded", - "models_excluded_hint": "This model is excluded by OAuth", + "models_excluded_badge": "Disabled", + "models_excluded_hint": "This OAuth model is disabled", "status_toggle_label": "Enabled", "status_enabled_success": "\"{{name}}\" enabled", "status_disabled_success": "\"{{name}}\" disabled", @@ -490,43 +490,43 @@ "result_file": "Persisted file" }, "oauth_excluded": { - "title": "OAuth Excluded Models", - "description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.", - "add": "Add Exclusion", - "add_title": "Add provider exclusion", - "edit_title": "Edit exclusions for {{provider}}", + "title": "OAuth Model Disablement", + "description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.", + "add": "Add Disablement", + "add_title": "Add provider model disablement", + "edit_title": "Edit model disablement for {{provider}}", "refresh": "Refresh", "refreshing": "Refreshing...", "provider_label": "Provider", "provider_auto": "Follow current filter", "provider_placeholder": "e.g. gemini-cli", "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", - "models_label": "Models to exclude", + "models_label": "Models to disable", "models_loading": "Loading models...", "models_unsupported": "Current CPA version does not support fetching model lists.", - "models_loaded": "{{count}} models loaded. Check the models to exclude.", + "models_loaded": "{{count}} models loaded. Check the models to disable.", "no_models_available": "No models available for this provider.", "save": "Save/Update", "saving": "Saving...", - "save_success": "Excluded models updated", - "save_failed": "Failed to update excluded models", + "save_success": "Model disablement updated", + "save_failed": "Failed to update model disablement", "delete": "Delete Provider", - "delete_confirm": "Delete the exclusion list for {{provider}}?", - "delete_success": "Exclusion list removed", - "delete_failed": "Failed to delete exclusion list", + "delete_confirm": "Delete model disablement for {{provider}}?", + "delete_success": "Provider model disablement removed", + "delete_failed": "Failed to delete model disablement", "deleting": "Deleting...", - "no_models": "No excluded models", - "model_count": "{{count}} models excluded", - "list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.", - "list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.", - "disconnected": "Connect to the server to view exclusions", - "load_failed": "Failed to load exclusion list", + "no_models": "No disabled models configured", + "model_count": "{{count}} models disabled", + "list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.", + "list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.", + "disconnected": "Connect to the server to view model disablement", + "load_failed": "Failed to load model disablement", "provider_required": "Please enter a provider first", "scope_all": "Scope: All providers", "scope_provider": "Scope: {{provider}}", - "upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", + "upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.", "upgrade_required_title": "Please upgrade CLI Proxy API", - "upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." + "upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again." }, "oauth_model_alias": { "title": "OAuth Model Aliases", @@ -887,9 +887,9 @@ "debug": "Debug Mode", "debug_desc": "Enable verbose debug logging", "commercial_mode": "Commercial Mode", - "commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency", + "commercial_mode_desc": "Disable high-overhead middleware to support high concurrency", "logging_to_file": "Log to File", - "logging_to_file_desc": "Save logs to rotating files", + "logging_to_file_desc": "Save logs to files", "usage_statistics": "Usage Statistics", "usage_statistics_desc": "Collect usage statistics", "logs_max_size": "Log File Size Limit (MB)" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7b62437..f657976 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -405,8 +405,8 @@ "models_empty_desc": "Возможно, учётные данные ещё не загружены сервером или к ним не привязаны модели.", "models_unsupported": "Функция не поддерживается в текущей версии", "models_unsupported_desc": "Обновите CLI Proxy API до последней версии и повторите попытку", - "models_excluded_badge": "Исключена", - "models_excluded_hint": "Эта модель исключена OAuth", + "models_excluded_badge": "Отключена", + "models_excluded_hint": "Эта OAuth-модель отключена", "status_toggle_label": "Включено", "status_enabled_success": "\"{{name}}\" включён", "status_disabled_success": "\"{{name}}\" отключён", @@ -493,43 +493,43 @@ "result_file": "Сохранённый файл" }, "oauth_excluded": { - "title": "Исключённые модели OAuth", - "description": "Исключения по провайдерам отображаются карточками; нажмите \"Изменить\", чтобы настроить. Поддерживаются шаблоны *. Объём зависит от фильтра файлов авторизации.", - "add": "Добавить исключение", - "add_title": "Добавление исключения для провайдера", - "edit_title": "Редактирование исключений для {{provider}}", + "title": "Отключение OAuth-моделей", + "description": "Отключения моделей по провайдерам отображаются карточками; нажмите карточку, чтобы изменить или удалить. Поддерживаются шаблоны *. Область зависит от фильтра файлов авторизации.", + "add": "Добавить отключение", + "add_title": "Добавление отключения моделей для провайдера", + "edit_title": "Редактирование отключения моделей для {{provider}}", "refresh": "Обновить", "refreshing": "Обновляется...", "provider_label": "Провайдер", "provider_auto": "Следовать текущему фильтру", "provider_placeholder": "например: gemini-cli", "provider_hint": "По умолчанию используется текущий фильтр; выберите существующего провайдера или введите новое имя.", - "models_label": "Модели для исключения", + "models_label": "Отключаемые модели", "models_loading": "Загрузка моделей...", "models_unsupported": "Текущая версия CPA не поддерживает загрузку списка моделей.", - "models_loaded": "Загружено моделей: {{count}}. Отметьте те, которые нужно исключить.", + "models_loaded": "Загружено моделей: {{count}}. Отметьте модели, которые нужно отключить.", "no_models_available": "Для этого провайдера нет доступных моделей.", "save": "Сохранить/обновить", "saving": "Сохранение...", - "save_success": "Исключения обновлены", - "save_failed": "Не удалось обновить исключения", + "save_success": "Отключение моделей обновлено", + "save_failed": "Не удалось обновить отключение моделей", "delete": "Удалить провайдера", - "delete_confirm": "Удалить список исключений для {{provider}}?", - "delete_success": "Список исключений удалён", - "delete_failed": "Не удалось удалить список исключений", + "delete_confirm": "Удалить отключение моделей для {{provider}}?", + "delete_success": "Отключение моделей провайдера удалено", + "delete_failed": "Не удалось удалить отключение моделей", "deleting": "Удаление...", - "no_models": "Исключённых моделей нет", - "model_count": "Исключено моделей: {{count}}", - "list_empty_all": "Исключений ещё нет — используйте \"Добавить исключение\".", - "list_empty_filtered": "В этом диапазоне исключений нет; нажмите \"Добавить исключение\".", - "disconnected": "Подключитесь к серверу, чтобы просматривать исключения", - "load_failed": "Не удалось загрузить список исключений", + "no_models": "Отключаемые модели не настроены", + "model_count": "Отключено моделей: {{count}}", + "list_empty_all": "Отключений моделей пока нет — используйте \"Добавить отключение\".", + "list_empty_filtered": "В этой области нет отключённых моделей; нажмите \"Добавить отключение\".", + "disconnected": "Подключитесь к серверу, чтобы просматривать отключение моделей", + "load_failed": "Не удалось загрузить отключение моделей", "provider_required": "Сначала укажите провайдера", "scope_all": "Область: все провайдеры", "scope_provider": "Область: {{provider}}", - "upgrade_required": "Эта функция требует более новой версии CLI Proxy API (CPA). Обновите систему.", + "upgrade_required": "Текущая версия CPA не поддерживает отключение OAuth-моделей. Пожалуйста, обновите систему.", "upgrade_required_title": "Пожалуйста, обновите CLI Proxy API", - "upgrade_required_desc": "Текущая версия сервера не поддерживает API исключённых моделей OAuth. Обновите CLI Proxy API (CPA) до последней версии." + "upgrade_required_desc": "Текущая версия сервера не поддерживает получение отключения OAuth-моделей. Обновите CPA (CLI Proxy API) до последней версии и повторите попытку." }, "oauth_model_alias": { "title": "Псевдонимы моделей OAuth", @@ -890,9 +890,9 @@ "debug": "Режим отладки", "debug_desc": "Включить подробные отладочные журналы", "commercial_mode": "Коммерческий режим", - "commercial_mode_desc": "Отключить тяжёлое промежуточное ПО, чтобы снизить расход памяти при высокой нагрузке", + "commercial_mode_desc": "Отключить тяжёлое промежуточное ПО для поддержки высокой нагрузки", "logging_to_file": "Журналировать в файл", - "logging_to_file_desc": "Сохранять журналы во вращающиеся файлы", + "logging_to_file_desc": "Сохранять журналы в файлы", "usage_statistics": "Статистика использования", "usage_statistics_desc": "Собирать статистику использования", "logs_max_size": "Максимальный размер файла журнала (МБ)", From f8d66917fdd010fbec738360db696bdc595a002d Mon Sep 17 00:00:00 2001 From: Supra4E8C <69194597+LTbinglingfeng@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:00:50 +0800 Subject: [PATCH 20/24] Revert "feat(ui): added claude quota display" From 3783bec983a0f4d6709b17bdac1f09ac00913ada Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 7 Feb 2026 22:37:12 +0800 Subject: [PATCH 21/24] fix(auth-files): refresh OAuth excluded/model-alias state when returning to Auth Files page --- src/pages/AuthFilesPage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 4954ae6..10b6a6b 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useInterval } from '@/hooks/useInterval'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; +import { usePageTransitionLayer } from '@/components/common/PageTransition'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; @@ -251,6 +252,8 @@ export function AuthFilesPage() { const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota); const setCodexQuota = useQuotaStore((state) => state.setCodexQuota); const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota); + const pageTransitionLayer = usePageTransitionLayer(); + const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true; const navigate = useNavigate(); const [files, setFiles] = useState([]); @@ -566,14 +569,15 @@ export function AuthFilesPage() { useHeaderRefresh(handleHeaderRefresh); useEffect(() => { + if (!isCurrentLayer) return; loadFiles(); loadKeyStats(); loadExcluded(); loadModelAlias(); - }, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]); + }, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]); // 定时刷新状态数据(每240秒) - useInterval(loadKeyStats, 240_000); + useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null); // 提取所有存在的类型 const existingTypes = useMemo(() => { From 6c2cd761ba081d06478c3c36e6bb2be852c8e349 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sun, 8 Feb 2026 09:42:00 +0800 Subject: [PATCH 22/24] refactor(core): harden API parsing and improve type safety --- src/components/common/PageTransition.tsx | 15 +- src/components/common/PageTransitionLayer.ts | 15 ++ src/components/layout/MainLayout.tsx | 10 +- .../modelAlias/ModelMappingDiagram.tsx | 2 - .../providers/ProviderNav/ProviderNav.tsx | 8 +- src/components/usage/hooks/useUsageData.ts | 4 +- src/hooks/useApi.ts | 7 +- src/hooks/useVisualConfig.ts | 175 +++++++++----- src/pages/AiProvidersOpenAIEditLayout.tsx | 2 +- src/pages/AuthFilesPage.tsx | 15 +- src/pages/DashboardPage.tsx | 15 +- src/pages/LoginPage.tsx | 25 +- src/pages/OAuthPage.tsx | 53 +++-- src/pages/SystemPage.tsx | 43 +++- src/services/api/ampcode.ts | 2 +- src/services/api/apiCall.ts | 21 +- src/services/api/apiKeys.ts | 6 +- src/services/api/authFiles.ts | 18 +- src/services/api/client.ts | 49 ++-- src/services/api/config.ts | 15 +- src/services/api/configFile.ts | 2 +- src/services/api/providers.ts | 37 +-- src/services/api/transformers.ts | 182 ++++++++------ src/services/api/usage.ts | 6 +- src/services/api/version.ts | 2 +- src/services/storage/secureStorage.ts | 6 +- src/stores/useAuthStore.ts | 10 +- src/stores/useConfigStore.ts | 57 +++-- src/stores/useModelsStore.ts | 5 +- src/types/api.ts | 8 +- src/types/authFile.ts | 2 +- src/types/common.ts | 2 +- src/types/config.ts | 2 +- src/types/log.ts | 2 +- src/types/provider.ts | 2 +- src/types/visualConfig.ts | 2 +- src/utils/helpers.ts | 31 +-- src/utils/models.ts | 11 +- src/utils/usage.ts | 224 +++++++++++------- 39 files changed, 689 insertions(+), 404 deletions(-) create mode 100644 src/components/common/PageTransitionLayer.ts diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 5efd7ad..2231120 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -1,14 +1,13 @@ import { ReactNode, - createContext, useCallback, - useContext, useLayoutEffect, useRef, useState, } from 'react'; import { useLocation, type Location } from 'react-router-dom'; import gsap from 'gsap'; +import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer'; import './PageTransition.scss'; interface PageTransitionProps { @@ -27,8 +26,6 @@ const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100; const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30; const IOS_EXIT_DIM_OPACITY = 0.72; -type LayerStatus = 'current' | 'exiting' | 'stacked'; - type Layer = { key: string; location: Location; @@ -39,16 +36,6 @@ type TransitionDirection = 'forward' | 'backward'; type TransitionVariant = 'vertical' | 'ios'; -type PageTransitionLayerContextValue = { - status: LayerStatus; -}; - -const PageTransitionLayerContext = createContext(null); - -export function usePageTransitionLayer() { - return useContext(PageTransitionLayerContext); -} - export function PageTransition({ render, getRouteOrder, diff --git a/src/components/common/PageTransitionLayer.ts b/src/components/common/PageTransitionLayer.ts new file mode 100644 index 0000000..3e33e86 --- /dev/null +++ b/src/components/common/PageTransitionLayer.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; + +export type LayerStatus = 'current' | 'exiting' | 'stacked'; + +type PageTransitionLayerContextValue = { + status: LayerStatus; +}; + +export const PageTransitionLayerContext = + createContext(null); + +export function usePageTransitionLayer() { + return useContext(PageTransitionLayerContext); +} + diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 074d9ea..982e78b 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -441,7 +441,8 @@ export function MainLayout() { setCheckingVersion(true); try { const data = await versionApi.checkLatest(); - const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? ''; + const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? ''; + const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? ''); const comparison = compareVersions(latest, serverVersion); if (!latest) { @@ -459,8 +460,11 @@ export function MainLayout() { } else { showNotification(t('system_info.version_is_latest'), 'success'); } - } catch (error: any) { - showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error'); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + const suffix = message ? `: ${message}` : ''; + showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error'); } finally { setCheckingVersion(false); } diff --git a/src/components/modelAlias/ModelMappingDiagram.tsx b/src/components/modelAlias/ModelMappingDiagram.tsx index 209a181..b207882 100644 --- a/src/components/modelAlias/ModelMappingDiagram.tsx +++ b/src/components/modelAlias/ModelMappingDiagram.tsx @@ -285,7 +285,6 @@ export const ModelMappingDiagram = forwardRef { // updateLines is called after layout is calculated, ensuring elements are in place. - updateLines(); const raf = requestAnimationFrame(updateLines); window.addEventListener('resize', updateLines); return () => { @@ -295,7 +294,6 @@ export const ModelMappingDiagram = forwardRef { - updateLines(); const raf = requestAnimationFrame(updateLines); return () => cancelAnimationFrame(raf); }, [providerGroupHeights, updateLines]); diff --git a/src/components/providers/ProviderNav/ProviderNav.tsx b/src/components/providers/ProviderNav/ProviderNav.tsx index 1dd7960..a6ee99f 100644 --- a/src/components/providers/ProviderNav/ProviderNav.tsx +++ b/src/components/providers/ProviderNav/ProviderNav.tsx @@ -1,7 +1,7 @@ import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useLocation } from 'react-router-dom'; -import { usePageTransitionLayer } from '@/components/common/PageTransition'; +import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; import { useThemeStore } from '@/stores'; import iconGemini from '@/assets/icons/gemini.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; @@ -135,8 +135,9 @@ export function ProviderNav() { window.addEventListener('scroll', handleScroll, { passive: true }); contentScroller?.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('resize', handleScroll); - handleScroll(); + const raf = requestAnimationFrame(handleScroll); return () => { + cancelAnimationFrame(raf); window.removeEventListener('scroll', handleScroll); window.removeEventListener('resize', handleScroll); contentScroller?.removeEventListener('scroll', handleScroll); @@ -168,7 +169,8 @@ export function ProviderNav() { useLayoutEffect(() => { if (!shouldShow) return; - updateIndicator(activeProvider); + const raf = requestAnimationFrame(() => updateIndicator(activeProvider)); + return () => cancelAnimationFrame(raf); }, [activeProvider, shouldShow, updateIndicator]); // Expose overlay height to the page, so it can reserve bottom padding and avoid being covered. diff --git a/src/components/usage/hooks/useUsageData.ts b/src/components/usage/hooks/useUsageData.ts index 06e669d..82bf526 100644 --- a/src/components/usage/hooks/useUsageData.ts +++ b/src/components/usage/hooks/useUsageData.ts @@ -45,8 +45,8 @@ export function useUsageData(): UseUsageDataReturn { setError(''); try { const data = await usageApi.getUsage(); - const payload = data?.usage ?? data; - setUsage(payload); + const payload = (data?.usage ?? data) as unknown; + setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null); } catch (err: unknown) { const message = err instanceof Error ? err.message : t('usage_stats.loading_error'); setError(message); diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index 7dc3150..8d5565f 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -13,7 +13,7 @@ interface UseApiOptions { successMessage?: string; } -export function useApi( +export function useApi( apiFunction: (...args: Args) => Promise, options: UseApiOptions = {} ) { @@ -38,8 +38,9 @@ export function useApi( options.onSuccess?.(result); return result; - } catch (err) { - const errorObj = err as Error; + } catch (err: unknown) { + const errorObj = + err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error'); setError(errorObj); if (options.showErrorNotification !== false) { diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts index 47d3cce..dac858c 100644 --- a/src/hooks/useVisualConfig.ts +++ b/src/hooks/useVisualConfig.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { PayloadFilterRule, @@ -123,20 +123,47 @@ function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueTyp return { valueType: 'string', value: String(raw ?? '') }; } +const PAYLOAD_PROTOCOL_VALUES = [ + 'openai', + 'openai-response', + 'gemini', + 'claude', + 'codex', + 'antigravity', +] as const; +type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number]; + +function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined { + if (typeof raw !== 'string') return undefined; + return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol) + ? (raw as PayloadProtocol) + : undefined; +} + function parsePayloadRules(rules: unknown): PayloadRule[] { if (!Array.isArray(rules)) return []; - return rules.map((rule, index) => ({ - id: `payload-rule-${index}`, - models: Array.isArray((rule as any)?.models) - ? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ - id: `model-${index}-${modelIndex}`, - name: typeof model === 'string' ? model : model?.name || '', - protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, - })) - : [], - params: (rule as any)?.params - ? Object.entries((rule as any).params as Record).map(([path, value], pIndex) => { + return rules.map((rule, index) => { + const record = asRecord(rule) ?? {}; + + const modelsRaw = record.models; + const models = Array.isArray(modelsRaw) + ? modelsRaw.map((model, modelIndex) => { + const modelRecord = asRecord(model); + const nameRaw = + typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? ''); + const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? ''); + return { + id: `model-${index}-${modelIndex}`, + name, + protocol: parsePayloadProtocol(modelRecord?.protocol), + }; + }) + : []; + + const paramsRecord = asRecord(record.params); + const params = paramsRecord + ? Object.entries(paramsRecord).map(([path, value], pIndex) => { const parsedValue = parsePayloadParamValue(value); return { id: `param-${index}-${pIndex}`, @@ -145,41 +172,55 @@ function parsePayloadRules(rules: unknown): PayloadRule[] { value: parsedValue.value, }; }) - : [], - })); + : []; + + return { id: `payload-rule-${index}`, models, params }; + }); } function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] { if (!Array.isArray(rules)) return []; - return rules.map((rule, index) => ({ - id: `payload-filter-rule-${index}`, - models: Array.isArray((rule as any)?.models) - ? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({ - id: `filter-model-${index}-${modelIndex}`, - name: typeof model === 'string' ? model : model?.name || '', - protocol: typeof model === 'object' ? (model?.protocol as any) : undefined, - })) - : [], - params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [], - })); + return rules.map((rule, index) => { + const record = asRecord(rule) ?? {}; + + const modelsRaw = record.models; + const models = Array.isArray(modelsRaw) + ? modelsRaw.map((model, modelIndex) => { + const modelRecord = asRecord(model); + const nameRaw = + typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? ''); + const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? ''); + return { + id: `filter-model-${index}-${modelIndex}`, + name, + protocol: parsePayloadProtocol(modelRecord?.protocol), + }; + }) + : []; + + const paramsRaw = record.params; + const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : []; + + return { id: `payload-filter-rule-${index}`, models, params }; + }); } -function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] { +function serializePayloadRulesForYaml(rules: PayloadRule[]): Array> { return rules .map((rule) => { const models = (rule.models || []) .filter((m) => m.name?.trim()) .map((m) => { - const obj: Record = { name: m.name.trim() }; + const obj: Record = { name: m.name.trim() }; if (m.protocol) obj.protocol = m.protocol; return obj; }); - const params: Record = {}; + const params: Record = {}; for (const param of rule.params || []) { if (!param.path?.trim()) continue; - let value: any = param.value; + let value: unknown = param.value; if (param.valueType === 'number') { const num = Number(param.value); value = Number.isFinite(num) ? num : param.value; @@ -200,13 +241,15 @@ function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] { .filter((rule) => rule.models.length > 0); } -function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] { +function serializePayloadFilterRulesForYaml( + rules: PayloadFilterRule[] +): Array> { return rules .map((rule) => { const models = (rule.models || []) .filter((m) => m.name?.trim()) .map((m) => { - const obj: Record = { name: m.name.trim() }; + const obj: Record = { name: m.name.trim() }; if (m.protocol) obj.protocol = m.protocol; return obj; }); @@ -225,33 +268,45 @@ export function useVisualConfig() { ...DEFAULT_VISUAL_VALUES, }); - const baselineValues = useRef({ ...DEFAULT_VISUAL_VALUES }); + const [baselineValues, setBaselineValues] = useState({ + ...DEFAULT_VISUAL_VALUES, + }); const visualDirty = useMemo(() => { - return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current); - }, [visualValues]); + return JSON.stringify(visualValues) !== JSON.stringify(baselineValues); + }, [baselineValues, visualValues]); const loadVisualValuesFromYaml = useCallback((yamlContent: string) => { try { - const parsed: any = parseYaml(yamlContent) || {}; + const parsedRaw: unknown = parseYaml(yamlContent) || {}; + const parsed = asRecord(parsedRaw) ?? {}; + const tls = asRecord(parsed.tls); + const remoteManagement = asRecord(parsed['remote-management']); + const quotaExceeded = asRecord(parsed['quota-exceeded']); + const routing = asRecord(parsed.routing); + const payload = asRecord(parsed.payload); + const streaming = asRecord(parsed.streaming); const newValues: VisualConfigValues = { - host: parsed.host || '', + host: typeof parsed.host === 'string' ? parsed.host : '', port: String(parsed.port ?? ''), - tlsEnable: Boolean(parsed.tls?.enable), - tlsCert: parsed.tls?.cert || '', - tlsKey: parsed.tls?.key || '', + tlsEnable: Boolean(tls?.enable), + tlsCert: typeof tls?.cert === 'string' ? tls.cert : '', + tlsKey: typeof tls?.key === 'string' ? tls.key : '', - rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']), - rmSecretKey: parsed['remote-management']?.['secret-key'] || '', - rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']), + rmAllowRemote: Boolean(remoteManagement?.['allow-remote']), + rmSecretKey: + typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '', + rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']), rmPanelRepo: - parsed['remote-management']?.['panel-github-repository'] ?? - parsed['remote-management']?.['panel-repo'] ?? - '', + typeof remoteManagement?.['panel-github-repository'] === 'string' + ? remoteManagement['panel-github-repository'] + : typeof remoteManagement?.['panel-repo'] === 'string' + ? remoteManagement['panel-repo'] + : '', - authDir: parsed['auth-dir'] || '', + authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '', apiKeysText: parseApiKeysText(parsed['api-keys']), debug: Boolean(parsed.debug), @@ -260,35 +315,36 @@ export function useVisualConfig() { logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''), usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']), - proxyUrl: parsed['proxy-url'] || '', + proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '', forceModelPrefix: Boolean(parsed['force-model-prefix']), requestRetry: String(parsed['request-retry'] ?? ''), maxRetryInterval: String(parsed['max-retry-interval'] ?? ''), wsAuth: Boolean(parsed['ws-auth']), - quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true), + quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true), quotaSwitchPreviewModel: Boolean( - parsed['quota-exceeded']?.['switch-preview-model'] ?? true + quotaExceeded?.['switch-preview-model'] ?? true ), - routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first', + routingStrategy: + routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin', - payloadDefaultRules: parsePayloadRules(parsed.payload?.default), - payloadOverrideRules: parsePayloadRules(parsed.payload?.override), - payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter), + payloadDefaultRules: parsePayloadRules(payload?.default), + payloadOverrideRules: parsePayloadRules(payload?.override), + payloadFilterRules: parsePayloadFilterRules(payload?.filter), streaming: { - keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''), - bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''), + keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''), + bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''), nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''), }, }; setVisualValuesState(newValues); - baselineValues.current = deepClone(newValues); + setBaselineValues(deepClone(newValues)); } catch { setVisualValuesState({ ...DEFAULT_VISUAL_VALUES }); - baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES); + setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES)); } }, []); @@ -331,7 +387,7 @@ export function useVisualConfig() { } setString(parsed, 'auth-dir', values.authDir); - if (values.apiKeysText !== baselineValues.current.apiKeysText) { + if (values.apiKeysText !== baselineValues.apiKeysText) { const apiKeys = values.apiKeysText .split('\n') .map((key) => key.trim()) @@ -419,7 +475,7 @@ export function useVisualConfig() { return currentYaml; } }, - [visualValues] + [baselineValues, visualValues] ); const setVisualValues = useCallback((newValues: Partial) => { @@ -444,6 +500,7 @@ export function useVisualConfig() { export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [ { value: '', label: '默认' }, { value: 'openai', label: 'OpenAI' }, + { value: 'openai-response', label: 'OpenAI Response' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude', label: 'Claude' }, { value: 'codex', label: 'Codex' }, diff --git a/src/pages/AiProvidersOpenAIEditLayout.tsx b/src/pages/AiProvidersOpenAIEditLayout.tsx index 321b0c4..3ecf6d0 100644 --- a/src/pages/AiProvidersOpenAIEditLayout.tsx +++ b/src/pages/AiProvidersOpenAIEditLayout.tsx @@ -243,7 +243,7 @@ export function AiProvidersOpenAIEditLayout() { setTestStatus('idle'); setTestMessage(''); } - }, [availableModels, loading, testModel]); + }, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]); const mergeDiscoveredModels = useCallback( (selectedModels: ModelInfo[]) => { diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 10b6a6b..49f992d 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useInterval } from '@/hooks/useInterval'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; -import { usePageTransitionLayer } from '@/components/common/PageTransition'; +import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; @@ -1475,11 +1475,14 @@ export function AuthFilesPage() { return GEMINI_CLI_CONFIG; }; - const getQuotaState = (type: QuotaProviderType, fileName: string) => { - if (type === 'antigravity') return antigravityQuota[fileName]; - if (type === 'codex') return codexQuota[fileName]; - return geminiCliQuota[fileName]; - }; + const getQuotaState = useCallback( + (type: QuotaProviderType, fileName: string) => { + if (type === 'antigravity') return antigravityQuota[fileName]; + if (type === 'codex') return codexQuota[fileName]; + return geminiCliQuota[fileName]; + }, + [antigravityQuota, codexQuota, geminiCliQuota] + ); const updateQuotaState = useCallback( ( diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 8054bfc..a6608c6 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -62,14 +62,23 @@ export function DashboardPage() { apiKeysCache.current = []; }, [apiBase, config?.apiKeys]); - const normalizeApiKeyList = (input: any): string[] => { + const normalizeApiKeyList = (input: unknown): string[] => { if (!Array.isArray(input)) return []; const seen = new Set(); const keys: string[] = []; input.forEach((item) => { - const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? ''; - const trimmed = String(value || '').trim(); + const record = + item !== null && typeof item === 'object' && !Array.isArray(item) + ? (item as Record) + : null; + const value = + typeof item === 'string' + ? item + : record + ? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key) + : ''; + const trimmed = String(value ?? '').trim(); if (!trimmed || seen.has(trimmed)) return; seen.add(trimmed); keys.push(trimmed); diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 8e8daf1..32068dc 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -15,11 +15,20 @@ import styles from './LoginPage.module.scss'; /** * 将 API 错误转换为本地化的用户友好消息 */ -function getLocalizedErrorMessage(error: any, t: (key: string) => string): string { - const apiError = error as ApiError; - const status = apiError?.status; - const code = apiError?.code; - const message = apiError?.message || ''; +type RedirectState = { from?: { pathname?: string } }; + +function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string { + const apiError = error as Partial; + const status = typeof apiError.status === 'number' ? apiError.status : undefined; + const code = typeof apiError.code === 'string' ? apiError.code : undefined; + const message = + error instanceof Error + ? error.message + : typeof apiError.message === 'string' + ? apiError.message + : typeof error === 'string' + ? error + : ''; // 根据 HTTP 状态码判断 if (status === 401) { @@ -99,7 +108,7 @@ export function LoginPage() { setAutoLoginSuccess(true); // 延迟跳转,让用户看到成功动画 setTimeout(() => { - const redirect = (location.state as any)?.from?.pathname || '/'; + const redirect = (location.state as RedirectState | null)?.from?.pathname || '/'; navigate(redirect, { replace: true }); }, 1500); } else { @@ -135,7 +144,7 @@ export function LoginPage() { }); showNotification(t('common.connected_status'), 'success'); navigate('/', { replace: true }); - } catch (err: any) { + } catch (err: unknown) { const message = getLocalizedErrorMessage(err, t); setError(message); showNotification(`${t('notification.login_failed')}: ${message}`, 'error'); @@ -155,7 +164,7 @@ export function LoginPage() { ); if (isAuthenticated && !autoLoading && !autoLoginSuccess) { - const redirect = (location.state as any)?.from?.pathname || '/'; + const redirect = (location.state as RedirectState | null)?.from?.pathname || '/'; return ; } diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index f0bc72d..d92bf9a 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -56,6 +56,21 @@ interface VertexImportState { result?: VertexImportResult; } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (isRecord(error) && typeof error.message === 'string') return error.message; + return typeof error === 'string' ? error : ''; +} + +function getErrorStatus(error: unknown): number | undefined { + if (!isRecord(error)) return undefined; + return typeof error.status === 'number' ? error.status : undefined; +} + const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [ { id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } }, { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, @@ -127,8 +142,8 @@ export function OAuthPage() { window.clearInterval(timer); delete timers.current[provider]; } - } catch (err: any) { - updateProviderState(provider, { status: 'error', error: err?.message, polling: false }); + } catch (err: unknown) { + updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false }); window.clearInterval(timer); delete timers.current[provider]; } @@ -159,9 +174,13 @@ export function OAuthPage() { if (res.state) { startPolling(provider, res.state); } - } catch (err: any) { - updateProviderState(provider, { status: 'error', error: err?.message, polling: false }); - showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error'); + } catch (err: unknown) { + const message = getErrorMessage(err); + updateProviderState(provider, { status: 'error', error: message, polling: false }); + showNotification( + `${t(getAuthKey(provider, 'oauth_start_error'))}${message ? ` ${message}` : ''}`, + 'error' + ); } }; @@ -190,13 +209,15 @@ export function OAuthPage() { await oauthApi.submitCallback(provider, redirectUrl); updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' }); showNotification(t('auth_login.oauth_callback_success'), 'success'); - } catch (err: any) { + } catch (err: unknown) { + const status = getErrorStatus(err); + const message = getErrorMessage(err); const errorMessage = - err?.status === 404 + status === 404 ? t('auth_login.oauth_callback_upgrade_hint', { defaultValue: 'Please update CLI Proxy API or check the connection.' }) - : err?.message; + : message || undefined; updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'error', @@ -236,15 +257,19 @@ export function OAuthPage() { })); showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error'); } - } catch (err: any) { - if (err?.status === 409) { + } catch (err: unknown) { + if (getErrorStatus(err) === 409) { const message = t('auth_login.iflow_cookie_config_duplicate'); setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' })); showNotification(message, 'warning'); return; } - setIflowCookie((prev) => ({ ...prev, loading: false, error: err?.message, errorType: 'error' })); - showNotification(`${t('auth_login.iflow_cookie_start_error')} ${err?.message || ''}`, 'error'); + const message = getErrorMessage(err); + setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' })); + showNotification( + `${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`, + 'error' + ); } }; @@ -292,8 +317,8 @@ export function OAuthPage() { }; setVertexState((prev) => ({ ...prev, loading: false, result })); showNotification(t('vertex_import.success'), 'success'); - } catch (err: any) { - const message = err?.message || ''; + } catch (err: unknown) { + const message = getErrorMessage(err); setVertexState((prev) => ({ ...prev, loading: false, diff --git a/src/pages/SystemPage.tsx b/src/pages/SystemPage.tsx index e4f3069..b1bea3a 100644 --- a/src/pages/SystemPage.tsx +++ b/src/pages/SystemPage.tsx @@ -83,14 +83,23 @@ export function SystemPage() { return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light; }; - const normalizeApiKeyList = (input: any): string[] => { + const normalizeApiKeyList = (input: unknown): string[] => { if (!Array.isArray(input)) return []; const seen = new Set(); const keys: string[] = []; input.forEach((item) => { - const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? ''; - const trimmed = String(value || '').trim(); + const record = + item !== null && typeof item === 'object' && !Array.isArray(item) + ? (item as Record) + : null; + const value = + typeof item === 'string' + ? item + : record + ? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key) + : ''; + const trimmed = String(value ?? '').trim(); if (!trimmed || seen.has(trimmed)) return; seen.add(trimmed); keys.push(trimmed); @@ -151,9 +160,12 @@ export function SystemPage() { type: hasModels ? 'success' : 'warning', message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty') }); - } catch (err: any) { - const message = `${t('system_info.models_error')}: ${err?.message || ''}`; - setModelStatus({ type: 'error', message }); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + const suffix = message ? `: ${message}` : ''; + const text = `${t('system_info.models_error')}${suffix}`; + setModelStatus({ type: 'error', message: text }); } }; @@ -219,9 +231,14 @@ export function SystemPage() { clearCache('request-log'); showNotification(t('notification.request_log_updated'), 'success'); setRequestLogModalOpen(false); - } catch (error: any) { + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : ''; updateConfigValue('request-log', previous); - showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error'); + showNotification( + `${t('notification.update_failed')}${message ? `: ${message}` : ''}`, + 'error' + ); } finally { setRequestLogSaving(false); } @@ -282,11 +299,11 @@ export function SystemPage() {
{buildTime}
-
-
{t('connection.status')}
-
{t(`common.${auth.connectionStatus}_status` as any)}
-
{auth.apiBase || '-'}
-
+
+
{t('connection.status')}
+
{t(`common.${auth.connectionStatus}_status`)}
+
{auth.apiBase || '-'}
+
diff --git a/src/services/api/ampcode.ts b/src/services/api/ampcode.ts index 668c558..392df6b 100644 --- a/src/services/api/ampcode.ts +++ b/src/services/api/ampcode.ts @@ -19,7 +19,7 @@ export const ampcodeApi = { clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'), async getModelMappings(): Promise { - const data = await apiClient.get('/ampcode/model-mappings'); + const data = await apiClient.get>('/ampcode/model-mappings'); const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data; return normalizeAmpcodeModelMappings(list); }, diff --git a/src/services/api/apiCall.ts b/src/services/api/apiCall.ts index 8bd7d20..043ae7c 100644 --- a/src/services/api/apiCall.ts +++ b/src/services/api/apiCall.ts @@ -13,14 +13,14 @@ export interface ApiCallRequest { data?: string; } -export interface ApiCallResult { +export interface ApiCallResult { statusCode: number; header: Record; bodyText: string; body: T | null; } -const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => { +const normalizeBody = (input: unknown): { bodyText: string; body: unknown | null } => { if (input === undefined || input === null) { return { bodyText: '', body: null }; } @@ -46,13 +46,24 @@ const normalizeBody = (input: unknown): { bodyText: string; body: any | null } = }; export const getApiCallErrorMessage = (result: ApiCallResult): string => { + const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + const status = result.statusCode; const body = result.body; const bodyText = result.bodyText; let message = ''; - if (body && typeof body === 'object') { - message = body?.error?.message || body?.error || body?.message || ''; + if (isRecord(body)) { + const errorValue = body.error; + if (isRecord(errorValue) && typeof errorValue.message === 'string') { + message = errorValue.message; + } else if (typeof errorValue === 'string') { + message = errorValue; + } + if (!message && typeof body.message === 'string') { + message = body.message; + } } else if (typeof body === 'string') { message = body; } @@ -71,7 +82,7 @@ export const apiCallApi = { payload: ApiCallRequest, config?: AxiosRequestConfig ): Promise => { - const response = await apiClient.post('/api-call', payload, config); + const response = await apiClient.post>('/api-call', payload, config); const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0); const header = (response?.header ?? response?.headers ?? {}) as Record; const { bodyText, body } = normalizeBody(response?.body); diff --git a/src/services/api/apiKeys.ts b/src/services/api/apiKeys.ts index a88ceb4..2672630 100644 --- a/src/services/api/apiKeys.ts +++ b/src/services/api/apiKeys.ts @@ -6,9 +6,9 @@ import { apiClient } from './client'; export const apiKeysApi = { async list(): Promise { - const data = await apiClient.get('/api-keys'); - const keys = (data && (data['api-keys'] ?? data.apiKeys)) as unknown; - return Array.isArray(keys) ? (keys as string[]) : []; + const data = await apiClient.get>('/api-keys'); + const keys = data['api-keys'] ?? data.apiKeys; + return Array.isArray(keys) ? keys.map((key) => String(key)) : []; }, replace: (keys: string[]) => apiClient.put('/api-keys', keys), diff --git a/src/services/api/authFiles.ts b/src/services/api/authFiles.ts index 05a6dd7..8d165ba 100644 --- a/src/services/api/authFiles.ts +++ b/src/services/api/authFiles.ts @@ -171,15 +171,25 @@ export const authFilesApi = { // 获取认证凭证支持的模型 async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> { - const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`); - return (data && Array.isArray(data['models'])) ? data['models'] : []; + const data = await apiClient.get>( + `/auth-files/models?name=${encodeURIComponent(name)}` + ); + const models = data.models ?? data['models']; + return Array.isArray(models) + ? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[]) + : []; }, // 获取指定 channel 的模型定义 async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> { const normalizedChannel = String(channel ?? '').trim().toLowerCase(); if (!normalizedChannel) return []; - const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`); - return (data && Array.isArray(data['models'])) ? data['models'] : []; + const data = await apiClient.get>( + `/model-definitions/${encodeURIComponent(normalizedChannel)}` + ); + const models = data.models ?? data['models']; + return Array.isArray(models) + ? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[]) + : []; } }; diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 79bc591..38f89ab 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -62,7 +62,10 @@ class ApiClient { return `${normalized}${MANAGEMENT_API_PREFIX}`; } - private readHeader(headers: Record | undefined, keys: string[]): string | null { + private readHeader( + headers: Record | undefined, + keys: string[] + ): string | null { if (!headers) return null; const normalizeValue = (value: unknown): string | null => { @@ -75,7 +78,7 @@ class ApiClient { return text ? text : null; }; - const headerGetter = (headers as { get?: (name: string) => any }).get; + const headerGetter = (headers as { get?: (name: string) => unknown }).get; if (typeof headerGetter === 'function') { for (const key of keys) { const match = normalizeValue(headerGetter.call(headers, key)); @@ -84,8 +87,8 @@ class ApiClient { } const entries = - typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function' - ? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries()) + typeof (headers as { entries?: () => Iterable<[string, unknown]> }).entries === 'function' + ? Array.from((headers as { entries: () => Iterable<[string, unknown]> }).entries()) : Object.entries(headers); const normalized = Object.fromEntries( @@ -147,10 +150,22 @@ class ApiClient { /** * 错误处理 */ - private handleError(error: any): ApiError { + private handleError(error: unknown): ApiError { + const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + if (axios.isAxiosError(error)) { - const responseData = error.response?.data as any; - const message = responseData?.error || responseData?.message || error.message || 'Request failed'; + const responseData: unknown = error.response?.data; + const responseRecord = isRecord(responseData) ? responseData : null; + const errorValue = responseRecord?.error; + const message = + typeof errorValue === 'string' + ? errorValue + : isRecord(errorValue) && typeof errorValue.message === 'string' + ? errorValue.message + : typeof responseRecord?.message === 'string' + ? responseRecord.message + : error.message || 'Request failed'; const apiError = new Error(message) as ApiError; apiError.name = 'ApiError'; apiError.status = error.response?.status; @@ -166,7 +181,9 @@ class ApiClient { return apiError; } - const fallback = new Error(error?.message || 'Unknown error occurred') as ApiError; + const fallbackMessage = + error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error occurred'; + const fallback = new Error(fallbackMessage) as ApiError; fallback.name = 'ApiError'; return fallback; } @@ -174,7 +191,7 @@ class ApiClient { /** * GET 请求 */ - async get(url: string, config?: AxiosRequestConfig): Promise { + async get(url: string, config?: AxiosRequestConfig): Promise { const response = await this.instance.get(url, config); return response.data; } @@ -182,7 +199,7 @@ class ApiClient { /** * POST 请求 */ - async post(url: string, data?: any, config?: AxiosRequestConfig): Promise { + async post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { const response = await this.instance.post(url, data, config); return response.data; } @@ -190,7 +207,7 @@ class ApiClient { /** * PUT 请求 */ - async put(url: string, data?: any, config?: AxiosRequestConfig): Promise { + async put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { const response = await this.instance.put(url, data, config); return response.data; } @@ -198,7 +215,7 @@ class ApiClient { /** * PATCH 请求 */ - async patch(url: string, data?: any, config?: AxiosRequestConfig): Promise { + async patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { const response = await this.instance.patch(url, data, config); return response.data; } @@ -206,7 +223,7 @@ class ApiClient { /** * DELETE 请求 */ - async delete(url: string, config?: AxiosRequestConfig): Promise { + async delete(url: string, config?: AxiosRequestConfig): Promise { const response = await this.instance.delete(url, config); return response.data; } @@ -221,7 +238,11 @@ class ApiClient { /** * 发送 FormData */ - async postForm(url: string, formData: FormData, config?: AxiosRequestConfig): Promise { + async postForm( + url: string, + formData: FormData, + config?: AxiosRequestConfig + ): Promise { const response = await this.instance.post(url, formData, { ...config, headers: { diff --git a/src/services/api/config.ts b/src/services/api/config.ts index 84df137..16096dc 100644 --- a/src/services/api/config.ts +++ b/src/services/api/config.ts @@ -72,8 +72,10 @@ export const configApi = { * 获取日志总大小上限(MB) */ async getLogsMaxTotalSizeMb(): Promise { - const data = await apiClient.get('/logs-max-total-size-mb'); - return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0; + const data = await apiClient.get>('/logs-max-total-size-mb'); + const value = data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; }, /** @@ -91,8 +93,8 @@ export const configApi = { * 获取强制模型前缀开关 */ async getForceModelPrefix(): Promise { - const data = await apiClient.get('/force-model-prefix'); - return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false; + const data = await apiClient.get>('/force-model-prefix'); + return Boolean(data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false); }, /** @@ -104,8 +106,9 @@ export const configApi = { * 获取路由策略 */ async getRoutingStrategy(): Promise { - const data = await apiClient.get('/routing/strategy'); - return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin'; + const data = await apiClient.get>('/routing/strategy'); + const strategy = data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy; + return typeof strategy === 'string' ? strategy : 'round-robin'; }, /** diff --git a/src/services/api/configFile.ts b/src/services/api/configFile.ts index d09e912..4e1734f 100644 --- a/src/services/api/configFile.ts +++ b/src/services/api/configFile.ts @@ -10,7 +10,7 @@ export const configFileApi = { responseType: 'text', headers: { Accept: 'application/yaml, text/yaml, text/plain' } }); - const data = response.data as any; + const data: unknown = response.data; if (typeof data === 'string') return data; if (data === undefined || data === null) return ''; return String(data); diff --git a/src/services/api/providers.ts b/src/services/api/providers.ts index 960852e..d56de47 100644 --- a/src/services/api/providers.ts +++ b/src/services/api/providers.ts @@ -18,12 +18,22 @@ import type { const serializeHeaders = (headers?: Record) => (headers && Object.keys(headers).length ? headers : undefined); +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const extractArrayPayload = (data: unknown, key: string): unknown[] => { + if (Array.isArray(data)) return data; + if (!isRecord(data)) return []; + const candidate = data[key] ?? data.items ?? data.data ?? data; + return Array.isArray(candidate) ? candidate : []; +}; + const serializeModelAliases = (models?: ModelAlias[]) => Array.isArray(models) ? models .map((model) => { if (!model?.name) return null; - const payload: Record = { name: model.name }; + const payload: Record = { name: model.name }; if (model.alias && model.alias !== model.name) { payload.alias = model.alias; } @@ -39,7 +49,7 @@ const serializeModelAliases = (models?: ModelAlias[]) => : undefined; const serializeApiKeyEntry = (entry: ApiKeyEntry) => { - const payload: Record = { 'api-key': entry.apiKey }; + const payload: Record = { 'api-key': entry.apiKey }; if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl; const headers = serializeHeaders(entry.headers); if (headers) payload.headers = headers; @@ -47,7 +57,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => { }; const serializeProviderKey = (config: ProviderKeyConfig) => { - const payload: Record = { 'api-key': config.apiKey }; + const payload: Record = { 'api-key': config.apiKey }; if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.baseUrl) payload['base-url'] = config.baseUrl; if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl; @@ -74,7 +84,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) => : undefined; const serializeVertexKey = (config: ProviderKeyConfig) => { - const payload: Record = { 'api-key': config.apiKey }; + const payload: Record = { 'api-key': config.apiKey }; if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.baseUrl) payload['base-url'] = config.baseUrl; if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl; @@ -86,7 +96,7 @@ const serializeVertexKey = (config: ProviderKeyConfig) => { }; const serializeGeminiKey = (config: GeminiKeyConfig) => { - const payload: Record = { 'api-key': config.apiKey }; + const payload: Record = { 'api-key': config.apiKey }; if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.baseUrl) payload['base-url'] = config.baseUrl; const headers = serializeHeaders(config.headers); @@ -98,7 +108,7 @@ const serializeGeminiKey = (config: GeminiKeyConfig) => { }; const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => { - const payload: Record = { + const payload: Record = { name: provider.name, 'base-url': provider.baseUrl, 'api-key-entries': Array.isArray(provider.apiKeyEntries) @@ -118,8 +128,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => { export const providersApi = { async getGeminiKeys(): Promise { const data = await apiClient.get('/gemini-api-key'); - const list = (data && (data['gemini-api-key'] ?? data.items ?? data)) as any; - if (!Array.isArray(list)) return []; + const list = extractArrayPayload(data, 'gemini-api-key'); return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[]; }, @@ -134,8 +143,7 @@ export const providersApi = { async getCodexConfigs(): Promise { const data = await apiClient.get('/codex-api-key'); - const list = (data && (data['codex-api-key'] ?? data.items ?? data)) as any; - if (!Array.isArray(list)) return []; + const list = extractArrayPayload(data, 'codex-api-key'); return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[]; }, @@ -150,8 +158,7 @@ export const providersApi = { async getClaudeConfigs(): Promise { const data = await apiClient.get('/claude-api-key'); - const list = (data && (data['claude-api-key'] ?? data.items ?? data)) as any; - if (!Array.isArray(list)) return []; + const list = extractArrayPayload(data, 'claude-api-key'); return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[]; }, @@ -166,8 +173,7 @@ export const providersApi = { async getVertexConfigs(): Promise { const data = await apiClient.get('/vertex-api-key'); - const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any; - if (!Array.isArray(list)) return []; + const list = extractArrayPayload(data, 'vertex-api-key'); return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[]; }, @@ -182,8 +188,7 @@ export const providersApi = { async getOpenAIProviders(): Promise { const data = await apiClient.get('/openai-compatibility'); - const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any; - if (!Array.isArray(list)) return []; + const list = extractArrayPayload(data, 'openai-compatibility'); return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[]; }, diff --git a/src/services/api/transformers.ts b/src/services/api/transformers.ts index c99686c..b6b5830 100644 --- a/src/services/api/transformers.ts +++ b/src/services/api/transformers.ts @@ -10,7 +10,10 @@ import type { import type { Config } from '@/types/config'; import { buildHeaderObject } from '@/utils/headers'; -const normalizeBoolean = (value: any): boolean | undefined => { +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const normalizeBoolean = (value: unknown): boolean | undefined => { if (value === undefined || value === null) return undefined; if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; @@ -22,11 +25,17 @@ const normalizeBoolean = (value: any): boolean | undefined => { return Boolean(value); }; -const normalizeModelAliases = (models: any): ModelAlias[] => { +const normalizeModelAliases = (models: unknown): ModelAlias[] => { if (!Array.isArray(models)) return []; return models .map((item) => { - if (!item) return null; + if (item === undefined || item === null) return null; + if (typeof item === 'string') { + const trimmed = item.trim(); + return trimmed ? ({ name: trimmed } satisfies ModelAlias) : null; + } + if (!isRecord(item)) return null; + const name = item.name || item.id || item.model; if (!name) return null; const alias = item.alias || item.display_name || item.displayName; @@ -37,7 +46,10 @@ const normalizeModelAliases = (models: any): ModelAlias[] => { entry.alias = String(alias); } if (priority !== undefined) { - entry.priority = Number(priority); + const parsed = Number(priority); + if (Number.isFinite(parsed)) { + entry.priority = parsed; + } } if (testModel) { entry.testModel = String(testModel); @@ -47,13 +59,17 @@ const normalizeModelAliases = (models: any): ModelAlias[] => { .filter(Boolean) as ModelAlias[]; }; -const normalizeHeaders = (headers: any) => { +const normalizeHeaders = (headers: unknown) => { if (!headers || typeof headers !== 'object') return undefined; - const normalized = buildHeaderObject(headers as Record); + const normalized = buildHeaderObject( + Array.isArray(headers) + ? (headers as Array<{ key: string; value: string }>) + : (headers as Record) + ); return Object.keys(normalized).length ? normalized : undefined; }; -const normalizeExcludedModels = (input: any): string[] => { +const normalizeExcludedModels = (input: unknown): string[] => { const rawList = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[\n,]/) : []; const seen = new Set(); const normalized: string[] = []; @@ -70,20 +86,22 @@ const normalizeExcludedModels = (input: any): string[] => { return normalized; }; -const normalizePrefix = (value: any): string | undefined => { +const normalizePrefix = (value: unknown): string | undefined => { if (value === undefined || value === null) return undefined; const trimmed = String(value).trim(); return trimmed ? trimmed : undefined; }; -const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => { - if (!entry) return null; - const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : ''); +const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => { + if (entry === undefined || entry === null) return null; + const record = isRecord(entry) ? entry : null; + const apiKey = + record?.['api-key'] ?? record?.apiKey ?? record?.key ?? (typeof entry === 'string' ? entry : ''); const trimmed = String(apiKey || '').trim(); if (!trimmed) return null; - const proxyUrl = entry['proxy-url'] ?? entry.proxyUrl; - const headers = normalizeHeaders(entry.headers); + const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined; + const headers = record ? normalizeHeaders(record.headers) : undefined; return { apiKey: trimmed, @@ -92,33 +110,38 @@ const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => { }; }; -const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => { - if (!item) return null; - const apiKey = item['api-key'] ?? item.apiKey ?? (typeof item === 'string' ? item : ''); +const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => { + if (item === undefined || item === null) return null; + const record = isRecord(item) ? item : null; + const apiKey = record?.['api-key'] ?? record?.apiKey ?? (typeof item === 'string' ? item : ''); const trimmed = String(apiKey || '').trim(); if (!trimmed) return null; const config: ProviderKeyConfig = { apiKey: trimmed }; - const prefix = normalizePrefix(item.prefix ?? item['prefix']); + const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']); if (prefix) config.prefix = prefix; - const baseUrl = item['base-url'] ?? item.baseUrl; - const proxyUrl = item['proxy-url'] ?? item.proxyUrl; + const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined; + const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined; if (baseUrl) config.baseUrl = String(baseUrl); if (proxyUrl) config.proxyUrl = String(proxyUrl); - const headers = normalizeHeaders(item.headers); + const headers = normalizeHeaders(record?.headers); if (headers) config.headers = headers; - const models = normalizeModelAliases(item.models); + const models = normalizeModelAliases(record?.models); if (models.length) config.models = models; const excludedModels = normalizeExcludedModels( - item['excluded-models'] ?? item.excludedModels ?? item['excluded_models'] ?? item.excluded_models + record?.['excluded-models'] ?? + record?.excludedModels ?? + record?.['excluded_models'] ?? + record?.excluded_models ); if (excludedModels.length) config.excludedModels = excludedModels; return config; }; -const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => { - if (!item) return null; - let apiKey = item['api-key'] ?? item.apiKey; +const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => { + if (item === undefined || item === null) return null; + const record = isRecord(item) ? item : null; + let apiKey = record?.['api-key'] ?? record?.apiKey; if (!apiKey && typeof item === 'string') { apiKey = item; } @@ -126,19 +149,19 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => { if (!trimmed) return null; const config: GeminiKeyConfig = { apiKey: trimmed }; - const prefix = normalizePrefix(item.prefix ?? item['prefix']); + const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']); if (prefix) config.prefix = prefix; - const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url']; + const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined; if (baseUrl) config.baseUrl = String(baseUrl); - const headers = normalizeHeaders(item.headers); + const headers = normalizeHeaders(record?.headers); if (headers) config.headers = headers; - const excludedModels = normalizeExcludedModels(item['excluded-models'] ?? item.excludedModels); + const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels); if (excludedModels.length) config.excludedModels = excludedModels; return config; }; -const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => { - if (!provider || typeof provider !== 'object') return null; +const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null => { + if (!isRecord(provider)) return null; const name = provider.name || provider.id; const baseUrl = provider['base-url'] ?? provider.baseUrl; if (!name || !baseUrl) return null; @@ -146,11 +169,11 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => let apiKeyEntries: ApiKeyEntry[] = []; if (Array.isArray(provider['api-key-entries'])) { apiKeyEntries = provider['api-key-entries'] - .map((entry: any) => normalizeApiKeyEntry(entry)) + .map((entry) => normalizeApiKeyEntry(entry)) .filter(Boolean) as ApiKeyEntry[]; } else if (Array.isArray(provider['api-keys'])) { apiKeyEntries = provider['api-keys'] - .map((key: any) => normalizeApiKeyEntry({ 'api-key': key })) + .map((key) => normalizeApiKeyEntry({ 'api-key': key })) .filter(Boolean) as ApiKeyEntry[]; } @@ -174,10 +197,10 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => return result; }; -const normalizeOauthExcluded = (payload: any): Record | undefined => { - if (!payload || typeof payload !== 'object') return undefined; +const normalizeOauthExcluded = (payload: unknown): Record | undefined => { + if (!isRecord(payload)) return undefined; const source = payload['oauth-excluded-models'] ?? payload.items ?? payload; - if (!source || typeof source !== 'object') return undefined; + if (!isRecord(source)) return undefined; const map: Record = {}; Object.entries(source).forEach(([provider, models]) => { const key = String(provider || '').trim(); @@ -188,13 +211,13 @@ const normalizeOauthExcluded = (payload: any): Record | undefi return map; }; -const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => { +const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] => { if (!Array.isArray(input)) return []; const seen = new Set(); const mappings: AmpcodeModelMapping[] = []; input.forEach((entry) => { - if (!entry || typeof entry !== 'object') return; + if (!isRecord(entry)) return; const from = String(entry.from ?? entry['from'] ?? '').trim(); const to = String(entry.to ?? entry['to'] ?? '').trim(); if (!from || !to) return; @@ -207,9 +230,10 @@ const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => { return mappings; }; -const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => { - const source = payload?.ampcode ?? payload; - if (!source || typeof source !== 'object') return undefined; +const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => { + const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload; + if (!isRecord(sourceRaw)) return undefined; + const source = sourceRaw; const config: AmpcodeConfig = {}; const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url']; @@ -237,70 +261,94 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => { /** * 规范化 /config 返回值 */ -export const normalizeConfigResponse = (raw: any): Config => { - const config: Config = { raw: raw || {} }; - if (!raw || typeof raw !== 'object') { +export const normalizeConfigResponse = (raw: unknown): Config => { + const config: Config = { raw: isRecord(raw) ? raw : {} }; + if (!isRecord(raw)) { return config; } - config.debug = raw.debug; - config.proxyUrl = raw['proxy-url'] ?? raw.proxyUrl; - config.requestRetry = raw['request-retry'] ?? raw.requestRetry; + config.debug = normalizeBoolean(raw.debug); + const proxyUrl = raw['proxy-url'] ?? raw.proxyUrl; + config.proxyUrl = + typeof proxyUrl === 'string' ? proxyUrl : proxyUrl === undefined || proxyUrl === null ? undefined : String(proxyUrl); + const requestRetry = raw['request-retry'] ?? raw.requestRetry; + if (typeof requestRetry === 'number' && Number.isFinite(requestRetry)) { + config.requestRetry = requestRetry; + } else if (typeof requestRetry === 'string' && requestRetry.trim() !== '') { + const parsed = Number(requestRetry); + if (Number.isFinite(parsed)) { + config.requestRetry = parsed; + } + } const quota = raw['quota-exceeded'] ?? raw.quotaExceeded; - if (quota && typeof quota === 'object') { + if (isRecord(quota)) { config.quotaExceeded = { - switchProject: quota['switch-project'] ?? quota.switchProject, - switchPreviewModel: quota['switch-preview-model'] ?? quota.switchPreviewModel + switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject), + switchPreviewModel: normalizeBoolean(quota['switch-preview-model'] ?? quota.switchPreviewModel) }; } - config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled; - config.requestLog = raw['request-log'] ?? raw.requestLog; - config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile; - config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb; - config.wsAuth = raw['ws-auth'] ?? raw.wsAuth; - config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix; - const routing = raw.routing; - if (routing && typeof routing === 'object') { - config.routingStrategy = routing.strategy ?? routing['strategy']; - } else { - config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy; + config.usageStatisticsEnabled = normalizeBoolean( + raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled + ); + config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog); + config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile); + const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb; + if (typeof logsMaxTotalSizeMb === 'number' && Number.isFinite(logsMaxTotalSizeMb)) { + config.logsMaxTotalSizeMb = logsMaxTotalSizeMb; + } else if (typeof logsMaxTotalSizeMb === 'string' && logsMaxTotalSizeMb.trim() !== '') { + const parsed = Number(logsMaxTotalSizeMb); + if (Number.isFinite(parsed)) { + config.logsMaxTotalSizeMb = parsed; + } + } + config.wsAuth = normalizeBoolean(raw['ws-auth'] ?? raw.wsAuth); + config.forceModelPrefix = normalizeBoolean(raw['force-model-prefix'] ?? raw.forceModelPrefix); + const routing = raw.routing; + const strategyRaw = isRecord(routing) + ? (routing.strategy ?? routing['strategy']) + : (raw['routing-strategy'] ?? raw.routingStrategy); + if (strategyRaw !== undefined && strategyRaw !== null) { + config.routingStrategy = String(strategyRaw); + } + const apiKeysRaw = raw['api-keys'] ?? raw.apiKeys; + if (Array.isArray(apiKeysRaw)) { + config.apiKeys = apiKeysRaw.map((key) => String(key)).filter((key) => key.trim() !== ''); } - config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys; const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys; if (Array.isArray(geminiList)) { config.geminiApiKeys = geminiList - .map((item: any) => normalizeGeminiKeyConfig(item)) + .map((item) => normalizeGeminiKeyConfig(item)) .filter(Boolean) as GeminiKeyConfig[]; } const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys; if (Array.isArray(codexList)) { config.codexApiKeys = codexList - .map((item: any) => normalizeProviderKeyConfig(item)) + .map((item) => normalizeProviderKeyConfig(item)) .filter(Boolean) as ProviderKeyConfig[]; } const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys; if (Array.isArray(claudeList)) { config.claudeApiKeys = claudeList - .map((item: any) => normalizeProviderKeyConfig(item)) + .map((item) => normalizeProviderKeyConfig(item)) .filter(Boolean) as ProviderKeyConfig[]; } const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys; if (Array.isArray(vertexList)) { config.vertexApiKeys = vertexList - .map((item: any) => normalizeProviderKeyConfig(item)) + .map((item) => normalizeProviderKeyConfig(item)) .filter(Boolean) as ProviderKeyConfig[]; } const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility; if (Array.isArray(openaiList)) { config.openaiCompatibility = openaiList - .map((item: any) => normalizeOpenAIProvider(item)) + .map((item) => normalizeOpenAIProvider(item)) .filter(Boolean) as OpenAIProviderConfig[]; } diff --git a/src/services/api/usage.ts b/src/services/api/usage.ts index 029ce09..6041c23 100644 --- a/src/services/api/usage.ts +++ b/src/services/api/usage.ts @@ -26,7 +26,7 @@ export const usageApi = { /** * 获取使用统计原始数据 */ - getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }), + getUsage: () => apiClient.get>('/usage', { timeout: USAGE_TIMEOUT_MS }), /** * 导出使用统计快照 @@ -42,10 +42,10 @@ export const usageApi = { /** * 计算密钥成功/失败统计,必要时会先获取 usage 数据 */ - async getKeyStats(usageData?: any): Promise { + async getKeyStats(usageData?: unknown): Promise { let payload = usageData; if (!payload) { - const response = await apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }); + const response = await apiClient.get>('/usage', { timeout: USAGE_TIMEOUT_MS }); payload = response?.usage ?? response; } return computeKeyStats(payload); diff --git a/src/services/api/version.ts b/src/services/api/version.ts index feaa6ba..1731feb 100644 --- a/src/services/api/version.ts +++ b/src/services/api/version.ts @@ -5,5 +5,5 @@ import { apiClient } from './client'; export const versionApi = { - checkLatest: () => apiClient.get('/latest-version') + checkLatest: () => apiClient.get>('/latest-version') }; diff --git a/src/services/storage/secureStorage.ts b/src/services/storage/secureStorage.ts index 73852d3..8f6ea05 100644 --- a/src/services/storage/secureStorage.ts +++ b/src/services/storage/secureStorage.ts @@ -13,7 +13,7 @@ class SecureStorageService { /** * 存储数据 */ - setItem(key: string, value: any, options: StorageOptions = {}): void { + setItem(key: string, value: unknown, options: StorageOptions = {}): void { const { encrypt = true } = options; if (value === null || value === undefined) { @@ -30,7 +30,7 @@ class SecureStorageService { /** * 获取数据 */ - getItem(key: string, options: StorageOptions = {}): T | null { + getItem(key: string, options: StorageOptions = {}): T | null { const { encrypt = true } = options; const raw = localStorage.getItem(key); @@ -84,7 +84,7 @@ class SecureStorageService { return; } - let parsed: any = raw; + let parsed: unknown = raw; try { parsed = JSON.parse(raw); } catch { diff --git a/src/stores/useAuthStore.ts b/src/stores/useAuthStore.ts index dcd5721..cf3470b 100644 --- a/src/stores/useAuthStore.ts +++ b/src/stores/useAuthStore.ts @@ -117,10 +117,16 @@ export const useAuthStore = create()( } else { localStorage.removeItem('isLoggedIn'); } - } catch (error: any) { + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Connection failed'; set({ connectionStatus: 'error', - connectionError: error.message || 'Connection failed' + connectionError: message || 'Connection failed' }); throw error; } diff --git a/src/stores/useConfigStore.ts b/src/stores/useConfigStore.ts index 8df0d46..da8f2a0 100644 --- a/src/stores/useConfigStore.ts +++ b/src/stores/useConfigStore.ts @@ -10,7 +10,7 @@ import { configApi } from '@/services/api/config'; import { CACHE_EXPIRY_MS } from '@/utils/constants'; interface ConfigCache { - data: any; + data: unknown; timestamp: number; } @@ -21,8 +21,11 @@ interface ConfigState { error: string | null; // 操作 - fetchConfig: (section?: RawConfigSection, forceRefresh?: boolean) => Promise; - updateConfigValue: (section: RawConfigSection, value: any) => void; + fetchConfig: { + (section?: undefined, forceRefresh?: boolean): Promise; + (section: RawConfigSection, forceRefresh?: boolean): Promise; + }; + updateConfigValue: (section: RawConfigSection, value: unknown) => void; clearCache: (section?: RawConfigSection) => void; isCacheValid: (section?: RawConfigSection) => boolean; } @@ -105,7 +108,7 @@ export const useConfigStore = create((set, get) => ({ loading: false, error: null, - fetchConfig: async (section, forceRefresh = false) => { + fetchConfig: (async (section?: RawConfigSection, forceRefresh: boolean = false) => { const { cache, isCacheValid } = get(); // 检查缓存 @@ -163,10 +166,12 @@ export const useConfigStore = create((set, get) => ({ }); return section ? extractSectionValue(data, section) : data; - } catch (error: any) { + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch config'; if (requestId === configRequestToken) { set({ - error: error.message || 'Failed to fetch config', + error: message || 'Failed to fetch config', loading: false }); } @@ -176,7 +181,7 @@ export const useConfigStore = create((set, get) => ({ inFlightConfigRequest = null; } } - }, + }) as ConfigState['fetchConfig'], updateConfigValue: (section, value) => { set((state) => { @@ -186,61 +191,61 @@ export const useConfigStore = create((set, get) => ({ switch (section) { case 'debug': - nextConfig.debug = value; + nextConfig.debug = value as Config['debug']; break; case 'proxy-url': - nextConfig.proxyUrl = value; + nextConfig.proxyUrl = value as Config['proxyUrl']; break; case 'request-retry': - nextConfig.requestRetry = value; + nextConfig.requestRetry = value as Config['requestRetry']; break; case 'quota-exceeded': - nextConfig.quotaExceeded = value; + nextConfig.quotaExceeded = value as Config['quotaExceeded']; break; case 'usage-statistics-enabled': - nextConfig.usageStatisticsEnabled = value; + nextConfig.usageStatisticsEnabled = value as Config['usageStatisticsEnabled']; break; case 'request-log': - nextConfig.requestLog = value; + nextConfig.requestLog = value as Config['requestLog']; break; case 'logging-to-file': - nextConfig.loggingToFile = value; + nextConfig.loggingToFile = value as Config['loggingToFile']; break; case 'logs-max-total-size-mb': - nextConfig.logsMaxTotalSizeMb = value; + nextConfig.logsMaxTotalSizeMb = value as Config['logsMaxTotalSizeMb']; break; case 'ws-auth': - nextConfig.wsAuth = value; + nextConfig.wsAuth = value as Config['wsAuth']; break; case 'force-model-prefix': - nextConfig.forceModelPrefix = value; + nextConfig.forceModelPrefix = value as Config['forceModelPrefix']; break; case 'routing/strategy': - nextConfig.routingStrategy = value; + nextConfig.routingStrategy = value as Config['routingStrategy']; break; case 'api-keys': - nextConfig.apiKeys = value; + nextConfig.apiKeys = value as Config['apiKeys']; break; case 'ampcode': - nextConfig.ampcode = value; + nextConfig.ampcode = value as Config['ampcode']; break; case 'gemini-api-key': - nextConfig.geminiApiKeys = value; + nextConfig.geminiApiKeys = value as Config['geminiApiKeys']; break; case 'codex-api-key': - nextConfig.codexApiKeys = value; + nextConfig.codexApiKeys = value as Config['codexApiKeys']; break; case 'claude-api-key': - nextConfig.claudeApiKeys = value; + nextConfig.claudeApiKeys = value as Config['claudeApiKeys']; break; case 'vertex-api-key': - nextConfig.vertexApiKeys = value; + nextConfig.vertexApiKeys = value as Config['vertexApiKeys']; break; case 'openai-compatibility': - nextConfig.openaiCompatibility = value; + nextConfig.openaiCompatibility = value as Config['openaiCompatibility']; break; case 'oauth-excluded-models': - nextConfig.oauthExcludedModels = value; + nextConfig.oauthExcludedModels = value as Config['oauthExcludedModels']; break; default: break; diff --git a/src/stores/useModelsStore.ts b/src/stores/useModelsStore.ts index 7a70d80..2c5a08d 100644 --- a/src/stores/useModelsStore.ts +++ b/src/stores/useModelsStore.ts @@ -52,8 +52,9 @@ export const useModelsStore = create((set, get) => ({ }); return list; - } catch (error: any) { - const message = error?.message || 'Failed to fetch models'; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch models'; set({ error: message, loading: false, diff --git a/src/types/api.ts b/src/types/api.ts index d5b3d50..7596f71 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -17,8 +17,8 @@ export interface ApiClientConfig { export interface RequestOptions { method?: HttpMethod; headers?: Record; - params?: Record; - data?: any; + params?: Record; + data?: unknown; } // 服务器版本信息 @@ -31,6 +31,6 @@ export interface ServerVersion { export type ApiError = Error & { status?: number; code?: string; - details?: any; - data?: any; + details?: unknown; + data?: unknown; }; diff --git a/src/types/authFile.ts b/src/types/authFile.ts index 2a56284..6431725 100644 --- a/src/types/authFile.ts +++ b/src/types/authFile.ts @@ -26,7 +26,7 @@ export interface AuthFileItem { runtimeOnly?: boolean | string; disabled?: boolean; modified?: number; - [key: string]: any; + [key: string]: unknown; } export interface AuthFilesResponse { diff --git a/src/types/common.ts b/src/types/common.ts index f78aa62..2ee1624 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -15,7 +15,7 @@ export interface Notification { duration?: number; } -export interface ApiResponse { +export interface ApiResponse { data?: T; error?: string; message?: string; diff --git a/src/types/config.ts b/src/types/config.ts index ddf29ef..a248efb 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -31,7 +31,7 @@ export interface Config { vertexApiKeys?: ProviderKeyConfig[]; openaiCompatibility?: OpenAIProviderConfig[]; oauthExcludedModels?: Record; - raw?: Record; + raw?: Record; } export type RawConfigSection = diff --git a/src/types/log.ts b/src/types/log.ts index bba56f8..73a0a4c 100644 --- a/src/types/log.ts +++ b/src/types/log.ts @@ -11,7 +11,7 @@ export interface LogEntry { timestamp: string; level: LogLevel; message: string; - details?: any; + details?: unknown; } // 日志筛选 diff --git a/src/types/provider.ts b/src/types/provider.ts index 85feacf..c0999f5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -43,5 +43,5 @@ export interface OpenAIProviderConfig { models?: ModelAlias[]; priority?: number; testModel?: string; - [key: string]: any; + [key: string]: unknown; } diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts index ff4242a..5aa91fe 100644 --- a/src/types/visualConfig.ts +++ b/src/types/visualConfig.ts @@ -10,7 +10,7 @@ export type PayloadParamEntry = { export type PayloadModelEntry = { id: string; name: string; - protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity'; + protocol?: 'openai' | 'openai-response' | 'gemini' | 'claude' | 'codex' | 'antigravity'; }; export type PayloadRule = { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 298e938..2b90768 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -15,13 +15,13 @@ export function normalizeArrayResponse(data: T | T[] | null | undefined): T[] /** * 防抖函数 */ -export function debounce any>( - func: T, +export function debounce( + func: (this: This, ...args: Args) => Return, delay: number -): (...args: Parameters) => void { +): (this: This, ...args: Args) => void { let timeoutId: ReturnType; - return function (this: any, ...args: Parameters) { + return function (this: This, ...args: Args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(this, args), delay); }; @@ -30,13 +30,13 @@ export function debounce any>( /** * 节流函数 */ -export function throttle any>( - func: T, +export function throttle( + func: (this: This, ...args: Args) => Return, limit: number -): (...args: Parameters) => void { +): (this: This, ...args: Args) => void { let inThrottle: boolean; - return function (this: any, ...args: Parameters) { + return function (this: This, ...args: Args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; @@ -67,16 +67,17 @@ export function generateId(): string { export function deepClone(obj: T): T { if (obj === null || typeof obj !== 'object') return obj; - if (obj instanceof Date) return new Date(obj.getTime()) as any; - if (obj instanceof Array) return obj.map((item) => deepClone(item)) as any; + if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T; + if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as unknown as T; - const clonedObj = {} as T; - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - clonedObj[key] = deepClone((obj as any)[key]); + const source = obj as Record; + const cloned: Record = {}; + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + cloned[key] = deepClone(source[key]); } } - return clonedObj; + return cloned as unknown as T; } /** diff --git a/src/utils/models.ts b/src/utils/models.ts index d3d48a2..5049fa0 100644 --- a/src/utils/models.ts +++ b/src/utils/models.ts @@ -30,12 +30,15 @@ const matchCategory = (text: string) => { return null; }; -export function normalizeModelList(payload: any, { dedupe = false } = {}): ModelInfo[] { - const toModel = (entry: any): ModelInfo | null => { +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export function normalizeModelList(payload: unknown, { dedupe = false } = {}): ModelInfo[] { + const toModel = (entry: unknown): ModelInfo | null => { if (typeof entry === 'string') { return { name: entry }; } - if (!entry || typeof entry !== 'object') { + if (!isRecord(entry)) { return null; } const name = entry.id || entry.name || entry.model || entry.value; @@ -57,7 +60,7 @@ export function normalizeModelList(payload: any, { dedupe = false } = {}): Model if (Array.isArray(payload)) { models = payload.map(toModel); - } else if (payload && typeof payload === 'object') { + } else if (isRecord(payload)) { if (Array.isArray(payload.data)) { models = payload.data.map(toModel); } else if (Array.isArray(payload.models)) { diff --git a/src/utils/usage.ts b/src/utils/usage.ts index ca50ed2..32888b1 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -64,7 +64,16 @@ export interface ApiStats { const TOKENS_PER_PRICE_UNIT = 1_000_000; const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2'; -const normalizeAuthIndex = (value: any) => { +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const getApisRecord = (usageData: unknown): Record | null => { + const usageRecord = isRecord(usageData) ? usageData : null; + const apisRaw = usageRecord ? usageRecord.apis : null; + return isRecord(apisRaw) ? apisRaw : null; +}; + +const normalizeAuthIndex = (value: unknown) => { if (typeof value === 'number' && Number.isFinite(value)) { return value.toString(); } @@ -306,24 +315,29 @@ export function formatUsd(value: number): string { /** * 从使用数据中收集所有请求明细 */ -export function collectUsageDetails(usageData: any): UsageDetail[] { - if (!usageData) { - return []; - } - const apis = usageData.apis || {}; +export function collectUsageDetails(usageData: unknown): UsageDetail[] { + const apis = getApisRecord(usageData); + if (!apis) return []; const details: UsageDetail[] = []; - Object.values(apis as Record).forEach((apiEntry) => { - const models = apiEntry?.models || {}; - Object.entries(models as Record).forEach(([modelName, modelEntry]) => { - const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : []; - modelDetails.forEach((detail: any) => { - if (detail && detail.timestamp) { - details.push({ - ...detail, - source: normalizeUsageSourceId(detail.source), - __modelName: modelName - }); - } + Object.values(apis).forEach((apiEntry) => { + if (!isRecord(apiEntry)) return; + const modelsRaw = apiEntry.models; + const models = isRecord(modelsRaw) ? modelsRaw : null; + if (!models) return; + + Object.entries(models).forEach(([modelName, modelEntry]) => { + if (!isRecord(modelEntry)) return; + const modelDetailsRaw = modelEntry.details; + const modelDetails = Array.isArray(modelDetailsRaw) ? modelDetailsRaw : []; + + modelDetails.forEach((detailRaw) => { + if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return; + const detail = detailRaw as unknown as UsageDetail; + details.push({ + ...detail, + source: normalizeUsageSourceId(detail.source), + __modelName: modelName, + }); }); }); }); @@ -333,8 +347,10 @@ export function collectUsageDetails(usageData: any): UsageDetail[] { /** * 从单条明细提取总 tokens */ -export function extractTotalTokens(detail: any): number { - const tokens = detail?.tokens || {}; +export function extractTotalTokens(detail: unknown): number { + const record = isRecord(detail) ? detail : null; + const tokensRaw = record?.tokens; + const tokens = isRecord(tokensRaw) ? tokensRaw : {}; if (typeof tokens.total_tokens === 'number') { return tokens.total_tokens; } @@ -352,7 +368,7 @@ export function extractTotalTokens(detail: any): number { /** * 计算 token 分类统计 */ -export function calculateTokenBreakdown(usageData: any): TokenBreakdown { +export function calculateTokenBreakdown(usageData: unknown): TokenBreakdown { const details = collectUsageDetails(usageData); if (!details.length) { return { cachedTokens: 0, reasoningTokens: 0 }; @@ -361,8 +377,8 @@ export function calculateTokenBreakdown(usageData: any): TokenBreakdown { let cachedTokens = 0; let reasoningTokens = 0; - details.forEach(detail => { - const tokens = detail?.tokens || {}; + details.forEach((detail) => { + const tokens = detail.tokens; cachedTokens += Math.max( typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0, typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0 @@ -378,7 +394,10 @@ export function calculateTokenBreakdown(usageData: any): TokenBreakdown { /** * 计算最近 N 分钟的 RPM/TPM */ -export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageData: any): RateStats { +export function calculateRecentPerMinuteRates( + windowMinutes: number = 30, + usageData: unknown +): RateStats { const details = collectUsageDetails(usageData); const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30; @@ -391,7 +410,7 @@ export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageD let requestCount = 0; let tokenCount = 0; - details.forEach(detail => { + details.forEach((detail) => { const timestamp = Date.parse(detail.timestamp); if (Number.isNaN(timestamp) || timestamp < windowStart) { return; @@ -413,15 +432,16 @@ export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageD /** * 从使用数据获取模型名称列表 */ -export function getModelNamesFromUsage(usageData: any): string[] { - if (!usageData) { - return []; - } - const apis = usageData.apis || {}; +export function getModelNamesFromUsage(usageData: unknown): string[] { + const apis = getApisRecord(usageData); + if (!apis) return []; const names = new Set(); - Object.values(apis as Record).forEach(apiEntry => { - const models = apiEntry?.models || {}; - Object.keys(models).forEach(modelName => { + Object.values(apis).forEach((apiEntry) => { + if (!isRecord(apiEntry)) return; + const modelsRaw = apiEntry.models; + const models = isRecord(modelsRaw) ? modelsRaw : null; + if (!models) return; + Object.keys(models).forEach((modelName) => { if (modelName) { names.add(modelName); } @@ -433,13 +453,13 @@ export function getModelNamesFromUsage(usageData: any): string[] { /** * 计算成本数据 */ -export function calculateCost(detail: any, modelPrices: Record): number { +export function calculateCost(detail: UsageDetail, modelPrices: Record): number { const modelName = detail.__modelName || ''; const price = modelPrices[modelName]; if (!price) { return 0; } - const tokens = detail?.tokens || {}; + const tokens = detail.tokens; const rawInputTokens = Number(tokens.input_tokens); const rawCompletionTokens = Number(tokens.output_tokens); const rawCachedTokensPrimary = Number(tokens.cached_tokens); @@ -463,7 +483,7 @@ export function calculateCost(detail: any, modelPrices: Record): number { +export function calculateTotalCost(usageData: unknown, modelPrices: Record): number { const details = collectUsageDetails(usageData); if (!details.length || !Object.keys(modelPrices).length) { return 0; @@ -483,16 +503,17 @@ export function loadModelPrices(): Record { if (!raw) { return {}; } - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) { return {}; } const normalized: Record = {}; - Object.entries(parsed).forEach(([model, price]: [string, any]) => { + Object.entries(parsed).forEach(([model, price]: [string, unknown]) => { if (!model) return; - const promptRaw = Number(price?.prompt); - const completionRaw = Number(price?.completion); - const cacheRaw = Number(price?.cache); + const priceRecord = isRecord(price) ? price : null; + const promptRaw = Number(priceRecord?.prompt); + const completionRaw = Number(priceRecord?.completion); + const cacheRaw = Number(priceRecord?.cache); if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) { return; @@ -536,21 +557,21 @@ export function saveModelPrices(prices: Record): void { /** * 获取 API 统计数据 */ -export function getApiStats(usageData: any, modelPrices: Record): ApiStats[] { - if (!usageData?.apis) { - return []; - } - const apis = usageData.apis; +export function getApiStats(usageData: unknown, modelPrices: Record): ApiStats[] { + const apis = getApisRecord(usageData); + if (!apis) return []; const result: ApiStats[] = []; - Object.entries(apis as Record).forEach(([endpoint, apiData]) => { + Object.entries(apis).forEach(([endpoint, apiData]) => { + if (!isRecord(apiData)) return; const models: Record = {}; let derivedSuccessCount = 0; let derivedFailureCount = 0; let totalCost = 0; - const modelsData = apiData?.models || {}; - Object.entries(modelsData as Record).forEach(([modelName, modelData]) => { + const modelsData = isRecord(apiData.models) ? apiData.models : {}; + Object.entries(modelsData).forEach(([modelName, modelData]) => { + if (!isRecord(modelData)) return; const details = Array.isArray(modelData.details) ? modelData.details : []; const hasExplicitCounts = typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number'; @@ -564,46 +585,50 @@ export function getApiStats(usageData: any, modelPrices: Record 0 && (!hasExplicitCounts || price)) { - details.forEach((detail: any) => { + details.forEach((detail) => { + const detailRecord = isRecord(detail) ? detail : null; if (!hasExplicitCounts) { - if (detail?.failed === true) { + if (detailRecord?.failed === true) { failureCount += 1; } else { successCount += 1; } } - if (price) { - totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); + if (price && detailRecord) { + totalCost += calculateCost( + { ...(detailRecord as unknown as UsageDetail), __modelName: modelName }, + modelPrices + ); } }); } models[modelName] = { - requests: modelData.total_requests || 0, + requests: Number(modelData.total_requests) || 0, successCount, failureCount, - tokens: modelData.total_tokens || 0 + tokens: Number(modelData.total_tokens) || 0 }; derivedSuccessCount += successCount; derivedFailureCount += failureCount; }); const hasApiExplicitCounts = - typeof apiData?.success_count === 'number' || typeof apiData?.failure_count === 'number'; + typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number'; const successCount = hasApiExplicitCounts - ? (Number(apiData?.success_count) || 0) + ? (Number(apiData.success_count) || 0) : derivedSuccessCount; const failureCount = hasApiExplicitCounts - ? (Number(apiData?.failure_count) || 0) + ? (Number(apiData.failure_count) || 0) : derivedFailureCount; result.push({ endpoint: maskUsageSensitiveValue(endpoint) || endpoint, - totalRequests: apiData.total_requests || 0, + totalRequests: Number(apiData.total_requests) || 0, successCount, failureCount, - totalTokens: apiData.total_tokens || 0, + totalTokens: Number(apiData.total_tokens) || 0, totalCost, models }); @@ -615,7 +640,7 @@ export function getApiStats(usageData: any, modelPrices: Record): Array<{ +export function getModelStats(usageData: unknown, modelPrices: Record): Array<{ model: string; requests: number; successCount: number; @@ -623,18 +648,22 @@ export function getModelStats(usageData: any, modelPrices: Record { - if (!usageData?.apis) { - return []; - } + const apis = getApisRecord(usageData); + if (!apis) return []; const modelMap = new Map(); - Object.values(usageData.apis as Record).forEach(apiData => { - const models = apiData?.models || {}; - Object.entries(models as Record).forEach(([modelName, modelData]) => { + Object.values(apis).forEach((apiData) => { + if (!isRecord(apiData)) return; + const modelsRaw = apiData.models; + const models = isRecord(modelsRaw) ? modelsRaw : null; + if (!models) return; + + Object.entries(models).forEach(([modelName, modelData]) => { + if (!isRecord(modelData)) return; const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 }; - existing.requests += modelData.total_requests || 0; - existing.tokens += modelData.total_tokens || 0; + existing.requests += Number(modelData.total_requests) || 0; + existing.tokens += Number(modelData.total_tokens) || 0; const details = Array.isArray(modelData.details) ? modelData.details : []; @@ -648,17 +677,21 @@ export function getModelStats(usageData: any, modelPrices: Record 0 && (!hasExplicitCounts || price)) { - details.forEach((detail: any) => { + details.forEach((detail) => { + const detailRecord = isRecord(detail) ? detail : null; if (!hasExplicitCounts) { - if (detail?.failed === true) { + if (detailRecord?.failed === true) { existing.failureCount += 1; } else { existing.successCount += 1; } } - if (price) { - existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); + if (price && detailRecord) { + existing.cost += calculateCost( + { ...(detailRecord as unknown as UsageDetail), __modelName: modelName }, + modelPrices + ); } }); } @@ -700,7 +733,10 @@ export function formatDayLabel(date: Date): string { /** * 构建小时级别的数据序列 */ -export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): { +export function buildHourlySeriesByModel( + usageData: unknown, + metric: 'requests' | 'tokens' = 'requests' +): { labels: string[]; dataByModel: Map; hasData: boolean; @@ -728,7 +764,7 @@ export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 't return { labels, dataByModel, hasData }; } - details.forEach(detail => { + details.forEach((detail) => { const timestamp = Date.parse(detail.timestamp); if (Number.isNaN(timestamp)) { return; @@ -767,7 +803,10 @@ export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 't /** * 构建日级别的数据序列 */ -export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): { +export function buildDailySeriesByModel( + usageData: unknown, + metric: 'requests' | 'tokens' = 'requests' +): { labels: string[]; dataByModel: Map; hasData: boolean; @@ -781,7 +820,7 @@ export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'to return { labels: [], dataByModel: new Map(), hasData }; } - details.forEach(detail => { + details.forEach((detail) => { const timestamp = Date.parse(detail.timestamp); if (Number.isNaN(timestamp)) { return; @@ -885,7 +924,7 @@ const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, * 构建图表数据 */ export function buildChartData( - usageData: any, + usageData: unknown, period: 'hour' | 'day' = 'day', metric: 'requests' | 'tokens' = 'requests', selectedModels: string[] = [] @@ -1034,8 +1073,9 @@ export function calculateStatusBarData( }; } -export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats { - if (!usageData) { +export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats { + const apis = getApisRecord(usageData); + if (!apis) { return { bySource: {}, byAuthIndex: {} }; } @@ -1049,17 +1089,21 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string return bucket[key]; }; - const apis = usageData.apis || {}; - Object.values(apis as any).forEach((apiEntry: any) => { - const models = apiEntry?.models || {}; + Object.values(apis).forEach((apiEntry) => { + if (!isRecord(apiEntry)) return; + const modelsRaw = apiEntry.models; + const models = isRecord(modelsRaw) ? modelsRaw : null; + if (!models) return; - Object.values(models as any).forEach((modelEntry: any) => { - const details = modelEntry?.details || []; + Object.values(models).forEach((modelEntry) => { + if (!isRecord(modelEntry)) return; + const details = Array.isArray(modelEntry.details) ? modelEntry.details : []; - details.forEach((detail: any) => { - const source = normalizeUsageSourceId(detail?.source, masker); - const authIndexKey = normalizeAuthIndex(detail?.auth_index); - const isFailed = detail?.failed === true; + details.forEach((detail) => { + const detailRecord = isRecord(detail) ? detail : null; + const source = normalizeUsageSourceId(detailRecord?.source, masker); + const authIndexKey = normalizeAuthIndex(detailRecord?.auth_index); + const isFailed = detailRecord?.failed === true; if (source) { const bucket = ensureBucket(sourceStats, source); From 535c303aecf9465309657e23f5df0a84c0a8d751 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 9 Feb 2026 00:21:56 +0800 Subject: [PATCH 23/24] fix(ai-providers): enforce required provider name for OpenAI-compatible save --- src/pages/AiProvidersOpenAIEditLayout.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/AiProvidersOpenAIEditLayout.tsx b/src/pages/AiProvidersOpenAIEditLayout.tsx index 3ecf6d0..05fc91e 100644 --- a/src/pages/AiProvidersOpenAIEditLayout.tsx +++ b/src/pages/AiProvidersOpenAIEditLayout.tsx @@ -280,12 +280,20 @@ export function AiProvidersOpenAIEditLayout() { ); const handleSave = useCallback(async () => { + const name = form.name.trim(); + const baseUrl = form.baseUrl.trim(); + + if (!name || !baseUrl) { + showNotification(t('notification.openai_provider_required'), 'error'); + return; + } + setSaving(true); try { const payload: OpenAIProviderConfig = { - name: form.name.trim(), + name, prefix: form.prefix?.trim() || undefined, - baseUrl: form.baseUrl.trim(), + baseUrl, headers: buildHeaderObject(form.headers), apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({ apiKey: entry.apiKey.trim(), From 027ab483d4114c75e8f15eccf768a5423fd58e23 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 9 Feb 2026 00:54:24 +0800 Subject: [PATCH 24/24] refactor(providers): remove deprecated AI provider modal implementations and unused modal types --- .../providers/AmpcodeSection/AmpcodeModal.tsx | 281 ------------ .../providers/ClaudeSection/ClaudeModal.tsx | 129 ------ .../providers/CodexSection/CodexModal.tsx | 117 ----- .../providers/GeminiSection/GeminiModal.tsx | 113 ----- .../OpenAISection/OpenAIDiscoveryModal.tsx | 194 -------- .../providers/OpenAISection/OpenAIModal.tsx | 433 ------------------ .../providers/VertexSection/VertexModal.tsx | 118 ----- src/components/providers/types.ts | 17 - 8 files changed, 1402 deletions(-) delete mode 100644 src/components/providers/AmpcodeSection/AmpcodeModal.tsx delete mode 100644 src/components/providers/ClaudeSection/ClaudeModal.tsx delete mode 100644 src/components/providers/CodexSection/CodexModal.tsx delete mode 100644 src/components/providers/GeminiSection/GeminiModal.tsx delete mode 100644 src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx delete mode 100644 src/components/providers/OpenAISection/OpenAIModal.tsx delete mode 100644 src/components/providers/VertexSection/VertexModal.tsx diff --git a/src/components/providers/AmpcodeSection/AmpcodeModal.tsx b/src/components/providers/AmpcodeSection/AmpcodeModal.tsx deleted file mode 100644 index 876a272..0000000 --- a/src/components/providers/AmpcodeSection/AmpcodeModal.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { Modal } from '@/components/ui/Modal'; -import { ModelInputList } from '@/components/ui/ModelInputList'; -import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; -import { useConfigStore, useNotificationStore } from '@/stores'; -import { ampcodeApi } from '@/services/api'; -import type { AmpcodeConfig } from '@/types'; -import { maskApiKey } from '@/utils/format'; -import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils'; -import type { AmpcodeFormState } from '../types'; - -interface AmpcodeModalProps { - isOpen: boolean; - disableControls: boolean; - onClose: () => void; - onBusyChange?: (busy: boolean) => void; -} - -export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) { - const { t } = useTranslation(); - const { showNotification, showConfirmation } = useNotificationStore(); - const config = useConfigStore((state) => state.config); - const updateConfigValue = useConfigStore((state) => state.updateConfigValue); - const clearCache = useConfigStore((state) => state.clearCache); - - const [form, setForm] = useState(() => buildAmpcodeFormState(null)); - const [loading, setLoading] = useState(false); - const [loaded, setLoaded] = useState(false); - const [mappingsDirty, setMappingsDirty] = useState(false); - const [error, setError] = useState(''); - const [saving, setSaving] = useState(false); - const initializedRef = useRef(false); - - const getErrorMessage = (err: unknown) => { - if (err instanceof Error) return err.message; - if (typeof err === 'string') return err; - return ''; - }; - - useEffect(() => { - onBusyChange?.(loading || saving); - }, [loading, saving, onBusyChange]); - - useEffect(() => { - if (!isOpen) { - initializedRef.current = false; - setLoading(false); - setSaving(false); - setError(''); - setLoaded(false); - setMappingsDirty(false); - setForm(buildAmpcodeFormState(null)); - onBusyChange?.(false); - return; - } - if (initializedRef.current) return; - initializedRef.current = true; - - setLoading(true); - setLoaded(false); - setMappingsDirty(false); - setError(''); - setForm(buildAmpcodeFormState(config?.ampcode ?? null)); - - void (async () => { - try { - const ampcode = await ampcodeApi.getAmpcode(); - setLoaded(true); - updateConfigValue('ampcode', ampcode); - clearCache('ampcode'); - setForm(buildAmpcodeFormState(ampcode)); - } catch (err: unknown) { - setError(getErrorMessage(err) || t('notification.refresh_failed')); - } finally { - setLoading(false); - } - })(); - }, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]); - - const clearAmpcodeUpstreamApiKey = async () => { - showConfirmation({ - title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }), - message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'), - variant: 'danger', - confirmText: t('common.confirm'), - onConfirm: async () => { - setSaving(true); - setError(''); - try { - await ampcodeApi.clearUpstreamApiKey(); - const previous = config?.ampcode ?? {}; - const next: AmpcodeConfig = { ...previous }; - delete next.upstreamApiKey; - updateConfigValue('ampcode', next); - clearCache('ampcode'); - showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success'); - } catch (err: unknown) { - const message = getErrorMessage(err); - setError(message); - showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); - } finally { - setSaving(false); - } - }, - }); - }; - - const performSaveAmpcode = async () => { - setSaving(true); - setError(''); - try { - const upstreamUrl = form.upstreamUrl.trim(); - const overrideKey = form.upstreamApiKey.trim(); - const modelMappings = entriesToAmpcodeMappings(form.mappingEntries); - - if (upstreamUrl) { - await ampcodeApi.updateUpstreamUrl(upstreamUrl); - } else { - await ampcodeApi.clearUpstreamUrl(); - } - - await ampcodeApi.updateForceModelMappings(form.forceModelMappings); - - if (loaded || mappingsDirty) { - if (modelMappings.length) { - await ampcodeApi.saveModelMappings(modelMappings); - } else { - await ampcodeApi.clearModelMappings(); - } - } - - if (overrideKey) { - await ampcodeApi.updateUpstreamApiKey(overrideKey); - } - - const previous = config?.ampcode ?? {}; - const next: AmpcodeConfig = { - upstreamUrl: upstreamUrl || undefined, - forceModelMappings: form.forceModelMappings, - }; - - if (previous.upstreamApiKey) { - next.upstreamApiKey = previous.upstreamApiKey; - } - - if (Array.isArray(previous.modelMappings)) { - next.modelMappings = previous.modelMappings; - } - - if (overrideKey) { - next.upstreamApiKey = overrideKey; - } - - if (loaded || mappingsDirty) { - if (modelMappings.length) { - next.modelMappings = modelMappings; - } else { - delete next.modelMappings; - } - } - - updateConfigValue('ampcode', next); - clearCache('ampcode'); - showNotification(t('notification.ampcode_updated'), 'success'); - onClose(); - } catch (err: unknown) { - const message = getErrorMessage(err); - setError(message); - showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); - } finally { - setSaving(false); - } - }; - - const saveAmpcode = async () => { - if (!loaded && mappingsDirty) { - showConfirmation({ - title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }), - message: t('ai_providers.ampcode_mappings_overwrite_confirm'), - variant: 'secondary', // Not dangerous, just a warning - confirmText: t('common.confirm'), - onConfirm: performSaveAmpcode, - }); - return; - } - - await performSaveAmpcode(); - }; - - return ( - - - - - } - > - {error &&
{error}
} - setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))} - disabled={loading || saving} - hint={t('ai_providers.ampcode_upstream_url_hint')} - /> - setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))} - disabled={loading || saving} - hint={t('ai_providers.ampcode_upstream_api_key_hint')} - /> -
-
- {t('ai_providers.ampcode_upstream_api_key_current', { - key: config?.ampcode?.upstreamApiKey - ? maskApiKey(config.ampcode.upstreamApiKey) - : t('common.not_set'), - })} -
- -
- -
- setForm((prev) => ({ ...prev, forceModelMappings: value }))} - disabled={loading || saving} - /> -
{t('ai_providers.ampcode_force_model_mappings_hint')}
-
- -
- - { - setMappingsDirty(true); - setForm((prev) => ({ ...prev, mappingEntries: entries })); - }} - addLabel={t('ai_providers.ampcode_model_mappings_add_btn')} - namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')} - aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')} - disabled={loading || saving} - /> -
{t('ai_providers.ampcode_model_mappings_hint')}
-
-
- ); -} diff --git a/src/components/providers/ClaudeSection/ClaudeModal.tsx b/src/components/providers/ClaudeSection/ClaudeModal.tsx deleted file mode 100644 index 980a933..0000000 --- a/src/components/providers/ClaudeSection/ClaudeModal.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/Button'; -import { HeaderInputList } from '@/components/ui/HeaderInputList'; -import { Input } from '@/components/ui/Input'; -import { Modal } from '@/components/ui/Modal'; -import { ModelInputList } from '@/components/ui/ModelInputList'; -import { modelsToEntries } from '@/components/ui/modelInputListUtils'; -import type { ProviderKeyConfig } from '@/types'; -import { headersToEntries } from '@/utils/headers'; -import { excludedModelsToText } from '../utils'; -import type { ProviderFormState, ProviderModalProps } from '../types'; - -interface ClaudeModalProps extends ProviderModalProps { - isSaving: boolean; -} - -const buildEmptyForm = (): ProviderFormState => ({ - apiKey: '', - prefix: '', - baseUrl: '', - proxyUrl: '', - headers: [], - models: [], - excludedModels: [], - modelEntries: [{ name: '', alias: '' }], - excludedText: '', -}); - -export function ClaudeModal({ - isOpen, - editIndex, - initialData, - onClose, - onSave, - isSaving, -}: ClaudeModalProps) { - const { t } = useTranslation(); - const [form, setForm] = useState(buildEmptyForm); - - useEffect(() => { - if (!isOpen) return; - if (initialData) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setForm({ - ...initialData, - headers: headersToEntries(initialData.headers), - modelEntries: modelsToEntries(initialData.models), - excludedText: excludedModelsToText(initialData.excludedModels), - }); - return; - } - setForm(buildEmptyForm()); - }, [initialData, isOpen]); - - return ( - - - - - } - > - setForm((prev) => ({ ...prev, apiKey: e.target.value }))} - /> - setForm((prev) => ({ ...prev, prefix: e.target.value }))} - hint={t('ai_providers.prefix_hint')} - /> - setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} - /> - setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} - /> - setForm((prev) => ({ ...prev, headers: entries }))} - addLabel={t('common.custom_headers_add')} - keyPlaceholder={t('common.custom_headers_key_placeholder')} - valuePlaceholder={t('common.custom_headers_value_placeholder')} - /> -
- - setForm((prev) => ({ ...prev, modelEntries: entries }))} - addLabel={t('ai_providers.claude_models_add_btn')} - namePlaceholder={t('common.model_name_placeholder')} - aliasPlaceholder={t('common.model_alias_placeholder')} - disabled={isSaving} - /> -
-
- -