From 6bc06d5f159fb18ac5c55469d0b871f74f289fe4 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 22 Jan 2026 05:16:38 +0800 Subject: [PATCH 01/18] refactor(providers): modular provider form with vertical scroll navigation - Refactor monolithic provider-form.tsx into modular architecture - Implement vertical scroll navigation replacing tab switching - Remove deprecated provider types: claude-auth, gemini-cli - Make openai-compatible permanently available (no feature flag) - Add framer-motion for smooth animations - Remove asterisks from i18n labels (SmartInputWrapper handles required markers) - Update provider types in vendor view and keys list - Add sections i18n for new form structure Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + messages/en/settings/index.ts | 1 + .../en/settings/providers/form/common.json | 9 +- messages/en/settings/providers/form/name.json | 2 +- .../en/settings/providers/form/sections.json | 23 +- messages/en/settings/providers/form/url.json | 2 +- messages/ja/settings/index.ts | 1 + .../ja/settings/providers/form/common.json | 9 +- messages/ja/settings/providers/form/name.json | 2 +- .../ja/settings/providers/form/sections.json | 24 +- messages/ja/settings/providers/form/url.json | 2 +- messages/ru/settings/index.ts | 1 + .../ru/settings/providers/form/common.json | 9 +- messages/ru/settings/providers/form/name.json | 2 +- .../ru/settings/providers/form/sections.json | 24 +- messages/ru/settings/providers/form/url.json | 2 +- messages/zh-CN/settings/index.ts | 1 + .../zh-CN/settings/providers/form/common.json | 9 +- .../zh-CN/settings/providers/form/name.json | 2 +- .../settings/providers/form/sections.json | 23 +- .../zh-CN/settings/providers/form/url.json | 2 +- messages/zh-TW/settings/index.ts | 1 + .../zh-TW/settings/providers/form/common.json | 9 +- .../zh-TW/settings/providers/form/name.json | 2 +- .../settings/providers/form/sections.json | 24 +- .../zh-TW/settings/providers/form/url.json | 2 +- package.json | 1 + ...ider-form.tsx => provider-form.legacy.tsx} | 0 .../provider-form/components/form-tab-nav.tsx | 229 ++++++++ .../provider-form/components/section-card.tsx | 222 ++++++++ .../_components/forms/provider-form/index.tsx | 525 ++++++++++++++++++ .../provider-form/provider-form-context.tsx | 327 +++++++++++ .../provider-form/provider-form-types.ts | 184 ++++++ .../sections/basic-info-section.tsx | 188 +++++++ .../provider-form/sections/limits-section.tsx | 429 ++++++++++++++ .../sections/network-section.tsx | 277 +++++++++ .../sections/routing-section.tsx | 500 +++++++++++++++++ .../sections/testing-section.tsx | 188 +++++++ .../_components/provider-vendor-view.tsx | 2 +- .../_components/vendor-keys-compact-list.tsx | 3 +- 40 files changed, 3241 insertions(+), 23 deletions(-) rename src/app/[locale]/settings/providers/_components/forms/{provider-form.tsx => provider-form.legacy.tsx} (100%) create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx diff --git a/.gitignore b/.gitignore index e927f4fd6..49cd7850e 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ docs-site/node_modules/ # local scratch tmp/ +.trae/ \ No newline at end of file diff --git a/messages/en/settings/index.ts b/messages/en/settings/index.ts index 414b5daa4..f8d4b6241 100644 --- a/messages/en/settings/index.ts +++ b/messages/en/settings/index.ts @@ -47,6 +47,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, diff --git a/messages/en/settings/providers/form/common.json b/messages/en/settings/providers/form/common.json index 9b1cb78c9..36534463d 100644 --- a/messages/en/settings/providers/form/common.json +++ b/messages/en/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "Core" + "core": "Core", + "tabs": { + "basic": "Basic", + "routing": "Routing", + "limits": "Limits", + "network": "Network", + "testing": "Testing" + } } diff --git a/messages/en/settings/providers/form/name.json b/messages/en/settings/providers/form/name.json index c29e1b62f..5ef645a94 100644 --- a/messages/en/settings/providers/form/name.json +++ b/messages/en/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "Provider Name *", + "label": "Provider Name", "placeholder": "e.g. Zhipu" } diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index c3f6ad646..62dfad529 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "Provider Identity", + "desc": "Set a unique name to identify this provider" + }, + "endpoint": { + "title": "API Endpoint", + "desc": "Configure the base URL for API requests" + }, + "auth": { + "title": "Authentication", + "desc": "Provide your API key for authentication" + } + }, "apiTest": { "desc": "Validate whether the selected provider type and model respond correctly. Defaults to the routing configuration unless overridden.", "summary": "Verify provider & model connectivity", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "Number of consecutive failures to trigger break", "label": "Failure Threshold", - "placeholder": "5" + "placeholder": "5", + "warning": "Setting to 0 disables the circuit breaker - use with caution" }, "maxRetryAttempts": { "desc": "Total tries (including the first call) before switching providers. Leave empty to use the system default.", @@ -129,6 +144,7 @@ "dailyResetTime": { "label": "Daily Reset Time (HH:mm)" }, + "desc": "Configure spending limits to control costs across different time windows", "limit5h": { "label": "5h Spend Limit (USD)", "placeholder": "Leave empty for unlimited" @@ -164,6 +180,11 @@ }, "title": "Rate Limit" }, + "limits": { + "timeBased": "Time-based Limits", + "dailyReset": "Daily Reset Settings", + "otherLimits": "Other Limits" + }, "routing": { "cacheTtl": { "desc": "Force prompt cache TTL; only affects requests with cache_control.", diff --git a/messages/en/settings/providers/form/url.json b/messages/en/settings/providers/form/url.json index 7b3c8a6ed..70f635779 100644 --- a/messages/en/settings/providers/form/url.json +++ b/messages/en/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API Address *", + "label": "API Address", "placeholder": "e.g. https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/ja/settings/index.ts b/messages/ja/settings/index.ts index 414b5daa4..f8d4b6241 100644 --- a/messages/ja/settings/index.ts +++ b/messages/ja/settings/index.ts @@ -47,6 +47,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, diff --git a/messages/ja/settings/providers/form/common.json b/messages/ja/settings/providers/form/common.json index 0eefe2718..d1cb1da2c 100644 --- a/messages/ja/settings/providers/form/common.json +++ b/messages/ja/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "コア" + "core": "コア", + "tabs": { + "basic": "基本情報", + "routing": "ルーティング", + "limits": "制限", + "network": "ネットワーク", + "testing": "テスト" + } } diff --git a/messages/ja/settings/providers/form/name.json b/messages/ja/settings/providers/form/name.json index 3e98216b7..ae0cbc008 100644 --- a/messages/ja/settings/providers/form/name.json +++ b/messages/ja/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "プロバイダー名 *", + "label": "プロバイダー名", "placeholder": "例: Zhipu" } diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 40edf78d4..84c69e2bf 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "プロバイダー識別", + "desc": "このプロバイダーを識別する一意の名前を設定" + }, + "endpoint": { + "title": "API エンドポイント", + "desc": "API リクエストのベース URL を設定" + }, + "auth": { + "title": "認証", + "desc": "認証用の API キーを入力" + } + }, "apiTest": { "desc": "プロバイダーのモデルが利用可能かをテストします。既定ではルーティング設定で選択したプロバイダー種別に従います。", "summary": "プロバイダーとモデルの接続性を確認", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "何回連続失敗でブレークするか", "label": "失敗しきい値(回)", - "placeholder": "5" + "placeholder": "5", + "warning": "0に設定するとサーキットブレーカーが無効になります - 慎重に使用してください" }, "maxRetryAttempts": { "desc": "初回呼び出しを含め、同一プロバイダーで試行する上限。超えると他のプロバイダーへ切り替えます。未入力の場合はデフォルト値を使用。", @@ -115,6 +130,8 @@ } }, "rateLimit": { + "title": "レート制限", + "desc": "異なる時間枠での消費制限を設定してコストを管理します", "dailyResetMode": { "desc": { "fixed": "毎日決まった時刻にクォータをリセット", @@ -164,6 +181,11 @@ }, "title": "レート制限" }, + "limits": { + "timeBased": "時間ベースの制限", + "dailyReset": "日次リセット設定", + "otherLimits": "その他の制限" + }, "routing": { "cacheTtl": { "desc": "プロンプトキャッシュのTTLを強制設定。cache_controlを含むリクエストにのみ適用されます。", diff --git a/messages/ja/settings/providers/form/url.json b/messages/ja/settings/providers/form/url.json index 7285ae182..df74080f9 100644 --- a/messages/ja/settings/providers/form/url.json +++ b/messages/ja/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API アドレス *", + "label": "API アドレス", "placeholder": "例: https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/ru/settings/index.ts b/messages/ru/settings/index.ts index 414b5daa4..f8d4b6241 100644 --- a/messages/ru/settings/index.ts +++ b/messages/ru/settings/index.ts @@ -47,6 +47,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, diff --git a/messages/ru/settings/providers/form/common.json b/messages/ru/settings/providers/form/common.json index fd4722431..fadb3569f 100644 --- a/messages/ru/settings/providers/form/common.json +++ b/messages/ru/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "Основная" + "core": "Основная", + "tabs": { + "basic": "Основные", + "routing": "Маршрутизация", + "limits": "Лимиты", + "network": "Сеть", + "testing": "Тестирование" + } } diff --git a/messages/ru/settings/providers/form/name.json b/messages/ru/settings/providers/form/name.json index 1140a7325..8cf655824 100644 --- a/messages/ru/settings/providers/form/name.json +++ b/messages/ru/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "Имя провайдера *", + "label": "Имя провайдера", "placeholder": "например: Zhipu" } diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 6da02eba6..afea91597 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "Идентификация провайдера", + "desc": "Укажите уникальное имя для идентификации этого провайдера" + }, + "endpoint": { + "title": "API Endpoint", + "desc": "Настройте базовый URL для API запросов" + }, + "auth": { + "title": "Аутентификация", + "desc": "Укажите API ключ для аутентификации" + } + }, "apiTest": { "desc": "Проверяет доступность модели у провайдера. По умолчанию соответствует типу провайдера, выбранному в настройках маршрутизации.", "summary": "Проверка связности провайдера и модели", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "Сколько подряд неудач для срабатывания", "label": "Порог неудач", - "placeholder": "5" + "placeholder": "5", + "warning": "Значение 0 отключает предохранитель - используйте с осторожностью" }, "maxRetryAttempts": { "desc": "Общее число попыток (включая первую) перед переключением на другого провайдера. Оставьте пустым для значения по умолчанию.", @@ -115,6 +130,8 @@ } }, "rateLimit": { + "title": "Ограничения", + "desc": "Настройка лимитов расходов для контроля затрат в разных временных окнах", "dailyResetMode": { "desc": { "fixed": "Сбрасывать квоту каждый день в фиксированное время", @@ -164,6 +181,11 @@ }, "title": "Ограничения" }, + "limits": { + "timeBased": "Временные ограничения", + "dailyReset": "Настройки ежедневного сброса", + "otherLimits": "Другие ограничения" + }, "routing": { "cacheTtl": { "desc": "Принудительно задать TTL кэша промптов; влияет только на запросы с cache_control.", diff --git a/messages/ru/settings/providers/form/url.json b/messages/ru/settings/providers/form/url.json index 8d0a5fcbe..43e85de68 100644 --- a/messages/ru/settings/providers/form/url.json +++ b/messages/ru/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "Адрес API *", + "label": "Адрес API", "placeholder": "например: https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/zh-CN/settings/index.ts b/messages/zh-CN/settings/index.ts index 414b5daa4..f8d4b6241 100644 --- a/messages/zh-CN/settings/index.ts +++ b/messages/zh-CN/settings/index.ts @@ -47,6 +47,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, diff --git a/messages/zh-CN/settings/providers/form/common.json b/messages/zh-CN/settings/providers/form/common.json index c394b7c11..88808dc1f 100644 --- a/messages/zh-CN/settings/providers/form/common.json +++ b/messages/zh-CN/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "核心" + "core": "核心", + "tabs": { + "basic": "基础信息", + "routing": "路由", + "limits": "限制", + "network": "网络", + "testing": "测试" + } } diff --git a/messages/zh-CN/settings/providers/form/name.json b/messages/zh-CN/settings/providers/form/name.json index 5d8ff44e6..1127edd0a 100644 --- a/messages/zh-CN/settings/providers/form/name.json +++ b/messages/zh-CN/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "服务商名称 *", + "label": "服务商名称", "placeholder": "例如: 智谱" } diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index 709ee56ec..831e8463a 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "供应商身份", + "desc": "设置唯一名称以标识此供应商" + }, + "endpoint": { + "title": "API 端点", + "desc": "配置 API 请求的基础 URL" + }, + "auth": { + "title": "身份认证", + "desc": "提供用于认证的 API 密钥" + } + }, "routing": { "title": "路由配置", "summary": { @@ -123,6 +137,7 @@ }, "rateLimit": { "title": "限流配置", + "desc": "配置不同时间窗口的消费限制以控制成本", "summary": { "fiveHour": "5h: {amount}", "daily": "日: {amount} (重置 {resetTime})", @@ -171,6 +186,11 @@ "placeholder": "0 表示无限制" } }, + "limits": { + "timeBased": "时间维度限制", + "dailyReset": "每日重置设置", + "otherLimits": "其他限制" + }, "circuitBreaker": { "title": "熔断器配置", "summary": "{failureThreshold} 次失败 / {openDuration} 分钟熔断 / {successThreshold} 次成功恢复 / 每个供应商最多 {maxRetryAttempts} 次尝试", @@ -178,7 +198,8 @@ "failureThreshold": { "label": "失败阈值(次)", "placeholder": "5", - "desc": "连续失败多少次后触发熔断" + "desc": "连续失败多少次后触发熔断", + "warning": "设为 0 将禁用熔断器 - 请谨慎使用" }, "openDuration": { "label": "熔断时长(分钟)", diff --git a/messages/zh-CN/settings/providers/form/url.json b/messages/zh-CN/settings/providers/form/url.json index 338c3eb4e..25e0bf334 100644 --- a/messages/zh-CN/settings/providers/form/url.json +++ b/messages/zh-CN/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API 地址 *", + "label": "API 地址", "placeholder": "例如: https://open.bigmodel.cn/api/anthropic" } diff --git a/messages/zh-TW/settings/index.ts b/messages/zh-TW/settings/index.ts index 414b5daa4..f8d4b6241 100644 --- a/messages/zh-TW/settings/index.ts +++ b/messages/zh-TW/settings/index.ts @@ -47,6 +47,7 @@ import providersFormWebsiteUrl from "./providers/form/websiteUrl.json"; const providersForm = { ...providersFormStrings, + ...providersFormCommon, apiTest: providersFormApiTest, buttons: providersFormButtons, common: providersFormCommon, diff --git a/messages/zh-TW/settings/providers/form/common.json b/messages/zh-TW/settings/providers/form/common.json index 56671c4d8..89489e20e 100644 --- a/messages/zh-TW/settings/providers/form/common.json +++ b/messages/zh-TW/settings/providers/form/common.json @@ -1,3 +1,10 @@ { - "core": "核心設定" + "core": "核心設定", + "tabs": { + "basic": "基本資訊", + "routing": "路由", + "limits": "限制", + "network": "網路", + "testing": "測試" + } } diff --git a/messages/zh-TW/settings/providers/form/name.json b/messages/zh-TW/settings/providers/form/name.json index f62ae57ca..b4f81798a 100644 --- a/messages/zh-TW/settings/providers/form/name.json +++ b/messages/zh-TW/settings/providers/form/name.json @@ -1,4 +1,4 @@ { - "label": "供應商名稱 *", + "label": "供應商名稱", "placeholder": "例如:智譜" } diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 8ac597e64..e5cb7ba6a 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -1,4 +1,18 @@ { + "basic": { + "identity": { + "title": "供應商身份", + "desc": "設定唯一名稱以識別此供應商" + }, + "endpoint": { + "title": "API 端點", + "desc": "設定 API 請求的基礎 URL" + }, + "auth": { + "title": "身份驗證", + "desc": "提供用於驗證的 API 金鑰" + } + }, "apiTest": { "desc": "測試供應商模型是否可用,預設與路由設定中選擇的供應商類型保持一致。", "summary": "驗證供應商與模型連通性", @@ -10,7 +24,8 @@ "failureThreshold": { "desc": "連續失敗多少次後觸發熔斷", "label": "失敗閾值(次)", - "placeholder": "5" + "placeholder": "5", + "warning": "設為 0 將停用斷路器 - 請謹慎使用" }, "maxRetryAttempts": { "desc": "包含首次呼叫在內,單一供應商最多嘗試次數,超過即切換。留空使用系統預設值。", @@ -115,6 +130,8 @@ } }, "rateLimit": { + "title": "流量限制", + "desc": "設定不同時間視窗的消費限制以控制成本", "dailyResetMode": { "desc": { "fixed": "每天固定時間點重置額度", @@ -164,6 +181,11 @@ }, "title": "流量限制" }, + "limits": { + "timeBased": "時間維度限制", + "dailyReset": "每日重置設定", + "otherLimits": "其他限制" + }, "routing": { "cacheTtl": { "desc": "強制設定 prompt cache TTL;僅影響包含 cache_control 的請求。", diff --git a/messages/zh-TW/settings/providers/form/url.json b/messages/zh-TW/settings/providers/form/url.json index 10f2d0b7f..ef27b7296 100644 --- a/messages/zh-TW/settings/providers/form/url.json +++ b/messages/zh-TW/settings/providers/form/url.json @@ -1,4 +1,4 @@ { - "label": "API 位址 *", + "label": "API 位址", "placeholder": "例如:https://open.bigmodel.cn/api/anthropic" } diff --git a/package.json b/package.json index c6eae1017..cd78c2182 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "dotenv": "^17", "drizzle-orm": "^0.44", "fetch-socks": "^1.3.2", + "framer-motion": "^12.28.1", "hono": "^4", "html2canvas": "^1", "ioredis": "^5", diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx similarity index 100% rename from src/app/[locale]/settings/providers/_components/forms/provider-form.tsx rename to src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx new file mode 100644 index 000000000..143ea95a1 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { motion } from "framer-motion"; +import { + FileText, + FlaskConical, + Gauge, + Network, + Route, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { cn } from "@/lib/utils"; +import type { TabId } from "../provider-form-types"; + +const TAB_CONFIG: { id: TabId; icon: typeof FileText; labelKey: string }[] = [ + { id: "basic", icon: FileText, labelKey: "tabs.basic" }, + { id: "routing", icon: Route, labelKey: "tabs.routing" }, + { id: "limits", icon: Gauge, labelKey: "tabs.limits" }, + { id: "network", icon: Network, labelKey: "tabs.network" }, + { id: "testing", icon: FlaskConical, labelKey: "tabs.testing" }, +]; + +interface FormTabNavProps { + activeTab: TabId; + onTabChange: (tab: TabId) => void; + disabled?: boolean; + tabStatus?: Partial>; +} + +export function FormTabNav({ + activeTab, + onTabChange, + disabled, + tabStatus = {}, +}: FormTabNavProps) { + const t = useTranslations("settings.providers.form"); + + const getStatusColor = (status?: "default" | "warning" | "configured") => { + switch (status) { + case "warning": + return "bg-yellow-500"; + case "configured": + return "bg-primary"; + default: + return null; + } + }; + + return ( + <> + {/* Desktop: Vertical Sidebar */} + + + {/* Tablet: Horizontal Tabs */} + + + {/* Mobile: Bottom Navigation */} + + + ); +} + +export { TAB_CONFIG }; diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx new file mode 100644 index 000000000..d51e9d8f1 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { motion } from "framer-motion"; +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface SectionCardProps { + title?: string; + description?: string; + icon?: LucideIcon; + children: ReactNode; + className?: string; + collapsible?: boolean; + defaultOpen?: boolean; + badge?: ReactNode; + variant?: "default" | "highlight" | "warning"; +} + +export function SectionCard({ + title, + description, + icon: Icon, + children, + className, + badge, + variant = "default", +}: SectionCardProps) { + const variantStyles = { + default: "border-border/50 hover:border-border", + highlight: "border-primary/30 hover:border-primary/50", + warning: "border-yellow-500/30 hover:border-yellow-500/50", + }; + + return ( + + {/* Glassmorphism gradient overlay */} +
+ + {/* Glow effect for highlight variant */} + {variant === "highlight" && ( +
+ )} + +
+ {(title || description) && ( +
+
+ {Icon && ( + + + + )} +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+
+ {badge} +
+ )} +
+ {children} +
+
+ + ); +} + +// Field group for visual grouping within a section +interface FieldGroupProps { + label?: string; + description?: string; + children: ReactNode; + className?: string; + horizontal?: boolean; +} + +export function FieldGroup({ + label, + description, + children, + className, + horizontal = false, +}: FieldGroupProps) { + return ( +
+ {(label || description) && ( +
+ {label && ( +
{label}
+ )} + {description && ( +

{description}

+ )} +
+ )} +
+ {children} +
+
+ ); +} + +// Smart input wrapper with inline validation +interface SmartInputWrapperProps { + label: string; + description?: string; + error?: string; + required?: boolean; + tooltip?: string; + children: ReactNode; + className?: string; +} + +export function SmartInputWrapper({ + label, + description, + error, + required, + children, + className, +}: SmartInputWrapperProps) { + return ( +
+
+ +
+ {children} + {error ? ( +

{error}

+ ) : description ? ( +

{description}

+ ) : null} +
+ ); +} + +// Toggle row for switch controls +interface ToggleRowProps { + label: string; + description?: string; + children: ReactNode; // Switch component + icon?: LucideIcon; + iconColor?: string; + className?: string; +} + +export function ToggleRow({ + label, + description, + children, + icon: Icon, + iconColor = "text-muted-foreground", + className, +}: ToggleRowProps) { + return ( +
+
+ {Icon && ( + + + + )} +
+
{label}
+ {description && ( +

{description}

+ )} +
+
+ {children} +
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx new file mode 100644 index 000000000..9b19c56ea --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -0,0 +1,525 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { addProvider, editProvider, removeProvider } from "@/actions/providers"; +import { getDistinctProviderGroupsAction } from "@/actions/request-filters"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogTrigger, + AlertDialogHeader as AlertHeader, + AlertDialogTitle as AlertTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { PROVIDER_DEFAULTS } from "@/lib/constants/provider.constants"; +import { isValidUrl } from "@/lib/utils/validation"; +import type { ProviderDisplay, ProviderType } from "@/types/provider"; +import { FormTabNav, TAB_CONFIG } from "./components/form-tab-nav"; +import { createInitialState, ProviderFormProvider, useProviderForm } from "./provider-form-context"; +import type { TabId } from "./provider-form-types"; +import { BasicInfoSection } from "./sections/basic-info-section"; +import { LimitsSection } from "./sections/limits-section"; +import { NetworkSection } from "./sections/network-section"; +import { RoutingSection } from "./sections/routing-section"; +import { TestingSection } from "./sections/testing-section"; + +export interface ProviderFormProps { + mode: "create" | "edit"; + onSuccess?: () => void; + provider?: ProviderDisplay; + cloneProvider?: ProviderDisplay; + enableMultiProviderTypes: boolean; + hideUrl?: boolean; + hideWebsiteUrl?: boolean; + preset?: { + name?: string; + url?: string; + websiteUrl?: string; + providerType?: ProviderType; + }; + urlResolver?: (providerType: ProviderType) => Promise; + allowedProviderTypes?: ProviderType[]; +} + +// Internal form component that uses context +function ProviderFormContent({ + onSuccess, + urlResolver, + autoUrlPending, +}: { + onSuccess?: () => void; + urlResolver?: (providerType: ProviderType) => Promise; + autoUrlPending: boolean; +}) { + const t = useTranslations("settings.providers.form"); + const { state, dispatch, mode, provider, hideUrl } = useProviderForm(); + const [isPending, startTransition] = useTransition(); + const isEdit = mode === "edit"; + + // Scroll navigation state - all sections stacked vertically + const contentRef = useRef(null); + const sectionRefs = useRef>({ + basic: null, + routing: null, + limits: null, + network: null, + testing: null, + }); + const isScrollingToSection = useRef(false); + + // Tab order for navigation + const tabOrder: TabId[] = ["basic", "routing", "limits", "network", "testing"]; + + // Scroll to section when tab is clicked + const scrollToSection = useCallback((tab: TabId) => { + const section = sectionRefs.current[tab]; + if (section && contentRef.current) { + isScrollingToSection.current = true; + const containerTop = contentRef.current.getBoundingClientRect().top; + const sectionTop = section.getBoundingClientRect().top; + const offset = sectionTop - containerTop + contentRef.current.scrollTop; + contentRef.current.scrollTo({ top: offset, behavior: "smooth" }); + setTimeout(() => { + isScrollingToSection.current = false; + }, 500); + } + }, []); + + // Detect active section based on scroll position + const handleScroll = useCallback(() => { + if (isScrollingToSection.current || !contentRef.current) return; + + const container = contentRef.current; + const containerRect = container.getBoundingClientRect(); + const scrollTop = container.scrollTop; + + // Find which section is at the top of the viewport + let activeSection: TabId = "basic"; + let minDistance = Infinity; + + for (const tab of tabOrder) { + const section = sectionRefs.current[tab]; + if (!section) continue; + + const sectionRect = section.getBoundingClientRect(); + const distanceFromTop = Math.abs(sectionRect.top - containerRect.top); + + if (distanceFromTop < minDistance) { + minDistance = distanceFromTop; + activeSection = tab; + } + } + + if (state.ui.activeTab !== activeSection) { + dispatch({ type: "SET_ACTIVE_TAB", payload: activeSection }); + } + }, [dispatch, state.ui.activeTab, tabOrder]); + + const handleTabChange = (tab: TabId) => { + dispatch({ type: "SET_ACTIVE_TAB", payload: tab }); + scrollToSection(tab); + }; + + // Sync isPending to context + useEffect(() => { + dispatch({ type: "SET_IS_PENDING", payload: isPending }); + }, [isPending, dispatch]); + + // Form validation + const validateForm = (): string | null => { + if (!state.basic.name.trim()) { + return t("errors.nameRequired"); + } + if (!hideUrl && !state.basic.url.trim()) { + return t("errors.urlRequired"); + } + if (!hideUrl && !isValidUrl(state.basic.url)) { + return t("errors.invalidUrl"); + } + if (!isEdit && !state.basic.key.trim()) { + return t("errors.keyRequired"); + } + return null; + }; + + // Check if failureThreshold needs confirmation + const needsFailureThresholdConfirm = () => { + const threshold = state.circuitBreaker.failureThreshold; + return threshold === 0 || (threshold !== undefined && threshold > 20); + }; + + // Actual form submission + const performSubmit = () => { + startTransition(async () => { + try { + // Convert duration from minutes to milliseconds + const openDurationMs = state.circuitBreaker.openDurationMinutes + ? state.circuitBreaker.openDurationMinutes * 60 * 1000 + : undefined; + + // Convert seconds to milliseconds for timeout fields + const firstByteTimeoutMs = + state.network.firstByteTimeoutStreamingSeconds !== undefined + ? state.network.firstByteTimeoutStreamingSeconds * 1000 + : undefined; + const idleTimeoutMs = + state.network.streamingIdleTimeoutSeconds !== undefined + ? state.network.streamingIdleTimeoutSeconds * 1000 + : undefined; + const nonStreamingTimeoutMs = + state.network.requestTimeoutNonStreamingSeconds !== undefined + ? state.network.requestTimeoutNonStreamingSeconds * 1000 + : undefined; + + const formData = { + name: state.basic.name.trim(), + url: state.basic.url.trim(), + key: state.basic.key.trim() || (isEdit ? "" : ""), + website_url: state.basic.websiteUrl?.trim() || null, + provider_type: state.routing.providerType, + preserve_client_ip: state.routing.preserveClientIp, + model_redirects: state.routing.modelRedirects, + allowed_models: state.routing.allowedModels.length > 0 ? state.routing.allowedModels : null, + join_claude_pool: state.routing.joinClaudePool, + priority: state.routing.priority, + weight: state.routing.weight, + cost_multiplier: state.routing.costMultiplier, + group_tag: state.routing.groupTag.length > 0 ? state.routing.groupTag.join(",") : null, + cache_ttl_preference: state.routing.cacheTtlPreference, + context_1m_preference: state.routing.context1mPreference, + codex_reasoning_effort_preference: state.routing.codexReasoningEffortPreference, + codex_reasoning_summary_preference: state.routing.codexReasoningSummaryPreference, + codex_text_verbosity_preference: state.routing.codexTextVerbosityPreference, + codex_parallel_tool_calls_preference: state.routing.codexParallelToolCallsPreference, + limit_5h_usd: state.rateLimit.limit5hUsd, + limit_daily_usd: state.rateLimit.limitDailyUsd, + daily_reset_mode: state.rateLimit.dailyResetMode, + daily_reset_time: state.rateLimit.dailyResetTime, + limit_weekly_usd: state.rateLimit.limitWeeklyUsd, + limit_monthly_usd: state.rateLimit.limitMonthlyUsd, + limit_total_usd: state.rateLimit.limitTotalUsd, + limit_concurrent_sessions: state.rateLimit.limitConcurrentSessions, + circuit_breaker_failure_threshold: state.circuitBreaker.failureThreshold, + circuit_breaker_open_duration: openDurationMs, + circuit_breaker_half_open_success_threshold: state.circuitBreaker.halfOpenSuccessThreshold, + max_retry_attempts: state.circuitBreaker.maxRetryAttempts, + proxy_url: state.network.proxyUrl?.trim() || null, + proxy_fallback_to_direct: state.network.proxyFallbackToDirect, + first_byte_timeout_streaming_ms: firstByteTimeoutMs, + streaming_idle_timeout_ms: idleTimeoutMs, + request_timeout_non_streaming_ms: nonStreamingTimeoutMs, + mcp_passthrough_type: state.mcp.mcpPassthroughType, + mcp_passthrough_url: state.mcp.mcpPassthroughUrl?.trim() || null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + }; + + if (isEdit && provider) { + const res = await editProvider(provider.id, formData); + if (!res.ok) { + toast.error(res.error || t("errors.updateFailed")); + return; + } + toast.success(t("success.updated")); + } else { + const res = await addProvider(formData); + if (!res.ok) { + toast.error(res.error || t("errors.createFailed")); + return; + } + toast.success(t("success.created")); + } + onSuccess?.(); + } catch (e) { + console.error("Form submission error:", e); + toast.error(isEdit ? t("errors.updateFailed") : t("errors.createFailed")); + } + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const error = validateForm(); + if (error) { + toast.error(error); + return; + } + + // Check if failureThreshold needs confirmation + if (needsFailureThresholdConfirm()) { + dispatch({ type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM", payload: true }); + return; + } + + performSubmit(); + }; + + const handleDelete = () => { + if (!provider) return; + startTransition(async () => { + try { + const res = await removeProvider(provider.id); + if (!res.ok) { + toast.error(res.error || t("errors.deleteFailed")); + return; + } + toast.success(t("success.deleted")); + onSuccess?.(); + } catch (e) { + console.error("Delete error:", e); + toast.error(t("errors.deleteFailed")); + } + }); + }; + + // Tab status indicators + const getTabStatus = (): Partial> => { + const status: Partial> = {}; + + // Basic - warning if required fields missing + if (!state.basic.name.trim() || (!hideUrl && !state.basic.url.trim())) { + status.basic = "warning"; + } + + // Routing - configured if models/redirects set + if ( + state.routing.allowedModels.length > 0 || + Object.keys(state.routing.modelRedirects).length > 0 + ) { + status.routing = "configured"; + } + + // Limits - configured if any limit set + if ( + state.rateLimit.limit5hUsd || + state.rateLimit.limitDailyUsd || + state.rateLimit.limitWeeklyUsd || + state.rateLimit.limitMonthlyUsd || + state.rateLimit.limitTotalUsd || + state.rateLimit.limitConcurrentSessions + ) { + status.limits = "configured"; + } + + // Network - configured if proxy set + if (state.network.proxyUrl) { + status.network = "configured"; + } + + // Testing - configured if MCP enabled + if (state.mcp.mcpPassthroughType !== "none") { + status.testing = "configured"; + } + + return status; + }; + + return ( +
+ {/* Form Layout */} +
+ {/* Tab Navigation */} + + + {/* All Sections Stacked Vertically */} +
+
+ {/* Basic Info Section */} +
{ sectionRefs.current.basic = el; }}> + +
+ + {/* Routing Section */} +
{ sectionRefs.current.routing = el; }}> + +
+ + {/* Limits Section */} +
{ sectionRefs.current.limits = el; }}> + +
+ + {/* Network Section */} +
{ sectionRefs.current.network = el; }}> + +
+ + {/* Testing Section */} +
{ sectionRefs.current.testing = el; }}> + +
+
+
+
+ + {/* Footer */} +
+ {isEdit ? ( +
+ + + + + {t("deleteDialog.title")} + + {t("deleteDialog.description", { name: provider?.name ?? "" })} + + + + {t("deleteDialog.cancel")} + + {t("deleteDialog.confirm")} + + + + + + +
+ ) : ( +
+ +
+ )} +
+ + {/* Failure Threshold Confirmation Dialog */} + + dispatch({ type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM", payload: open }) + } + > + + + {t("failureThresholdConfirmDialog.title")} + +
+ {state.circuitBreaker.failureThreshold === 0 ? ( +

+ {t("failureThresholdConfirmDialog.descriptionDisabledPrefix")} + {t("failureThresholdConfirmDialog.descriptionDisabledValue")} + {t("failureThresholdConfirmDialog.descriptionDisabledMiddle")} + {t("failureThresholdConfirmDialog.descriptionDisabledAction")} + {t("failureThresholdConfirmDialog.descriptionDisabledSuffix")} +

+ ) : ( +

+ {t("failureThresholdConfirmDialog.descriptionHighValuePrefix")} + {state.circuitBreaker.failureThreshold} + {t("failureThresholdConfirmDialog.descriptionHighValueSuffix")} +

+ )} +

{t("failureThresholdConfirmDialog.confirmQuestion")}

+
+
+
+ + {t("failureThresholdConfirmDialog.cancel")} + { + dispatch({ type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM", payload: false }); + performSubmit(); + }} + > + {t("failureThresholdConfirmDialog.confirm")} + + +
+
+
+ ); +} + +// Main exported component with provider wrapper +export function ProviderForm({ + mode, + onSuccess, + provider, + cloneProvider, + enableMultiProviderTypes, + hideUrl = false, + hideWebsiteUrl = false, + preset, + urlResolver, + allowedProviderTypes, +}: ProviderFormProps) { + const [groupSuggestions, setGroupSuggestions] = useState([]); + const [autoUrlPending, setAutoUrlPending] = useState(false); + + // Fetch group suggestions + useEffect(() => { + const fetchGroups = async () => { + try { + const res = await getDistinctProviderGroupsAction(); + if (res.ok && res.data) { + setGroupSuggestions(res.data); + } + } catch (e) { + console.error("Failed to fetch group suggestions:", e); + } + }; + fetchGroups(); + }, []); + + // Handle URL resolver for preset provider types + useEffect(() => { + if (urlResolver && preset?.providerType && !preset?.url) { + setAutoUrlPending(true); + urlResolver(preset.providerType) + .then((resolvedUrl) => { + if (resolvedUrl) { + // URL will be set through preset in createInitialState + } + }) + .finally(() => { + setAutoUrlPending(false); + }); + } + }, [urlResolver, preset?.providerType, preset?.url]); + + return ( + + + + ); +} + +export default ProviderForm; diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx new file mode 100644 index 000000000..c1c4a7e10 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { createContext, useContext, useReducer, type ReactNode } from "react"; +import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; +import type { ProviderDisplay } from "@/types/provider"; +import type { + FormMode, + ProviderFormAction, + ProviderFormContextValue, + ProviderFormState, +} from "./provider-form-types"; + +// Initial state factory +export function createInitialState( + mode: FormMode, + provider?: ProviderDisplay, + cloneProvider?: ProviderDisplay, + preset?: { + name?: string; + url?: string; + websiteUrl?: string; + providerType?: "claude" | "claude-auth" | "codex" | "gemini" | "gemini-cli" | "openai-compatible"; + } +): ProviderFormState { + const isEdit = mode === "edit"; + const sourceProvider = isEdit ? provider : cloneProvider; + + return { + basic: { + name: isEdit + ? (provider?.name ?? "") + : cloneProvider + ? `${cloneProvider.name}_Copy` + : (preset?.name ?? ""), + url: sourceProvider?.url ?? preset?.url ?? "", + key: "", + websiteUrl: sourceProvider?.websiteUrl ?? preset?.websiteUrl ?? "", + }, + routing: { + providerType: sourceProvider?.providerType ?? preset?.providerType ?? "claude", + groupTag: sourceProvider?.groupTag + ? sourceProvider.groupTag + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : [], + preserveClientIp: sourceProvider?.preserveClientIp ?? false, + modelRedirects: sourceProvider?.modelRedirects ?? {}, + allowedModels: sourceProvider?.allowedModels ?? [], + joinClaudePool: sourceProvider?.joinClaudePool ?? false, + priority: sourceProvider?.priority ?? 0, + weight: sourceProvider?.weight ?? 1, + costMultiplier: sourceProvider?.costMultiplier ?? 1.0, + cacheTtlPreference: sourceProvider?.cacheTtlPreference ?? "inherit", + context1mPreference: + (sourceProvider?.context1mPreference as "inherit" | "force_enable" | "disabled") ?? + "inherit", + codexReasoningEffortPreference: sourceProvider?.codexReasoningEffortPreference ?? "inherit", + codexReasoningSummaryPreference: sourceProvider?.codexReasoningSummaryPreference ?? "inherit", + codexTextVerbosityPreference: sourceProvider?.codexTextVerbosityPreference ?? "inherit", + codexParallelToolCallsPreference: + sourceProvider?.codexParallelToolCallsPreference ?? "inherit", + }, + rateLimit: { + limit5hUsd: sourceProvider?.limit5hUsd ?? null, + limitDailyUsd: sourceProvider?.limitDailyUsd ?? null, + dailyResetMode: sourceProvider?.dailyResetMode ?? "fixed", + dailyResetTime: sourceProvider?.dailyResetTime ?? "00:00", + limitWeeklyUsd: sourceProvider?.limitWeeklyUsd ?? null, + limitMonthlyUsd: sourceProvider?.limitMonthlyUsd ?? null, + limitTotalUsd: sourceProvider?.limitTotalUsd ?? null, + limitConcurrentSessions: sourceProvider?.limitConcurrentSessions ?? null, + }, + circuitBreaker: { + failureThreshold: sourceProvider?.circuitBreakerFailureThreshold, + openDurationMinutes: sourceProvider?.circuitBreakerOpenDuration + ? sourceProvider.circuitBreakerOpenDuration / 60000 + : undefined, + halfOpenSuccessThreshold: sourceProvider?.circuitBreakerHalfOpenSuccessThreshold, + maxRetryAttempts: sourceProvider?.maxRetryAttempts ?? null, + }, + network: { + proxyUrl: sourceProvider?.proxyUrl ?? "", + proxyFallbackToDirect: sourceProvider?.proxyFallbackToDirect ?? false, + firstByteTimeoutStreamingSeconds: (() => { + const ms = sourceProvider?.firstByteTimeoutStreamingMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + })(), + streamingIdleTimeoutSeconds: (() => { + const ms = sourceProvider?.streamingIdleTimeoutMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + })(), + requestTimeoutNonStreamingSeconds: (() => { + const ms = sourceProvider?.requestTimeoutNonStreamingMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + })(), + }, + mcp: { + mcpPassthroughType: sourceProvider?.mcpPassthroughType ?? "none", + mcpPassthroughUrl: sourceProvider?.mcpPassthroughUrl ?? "", + }, + ui: { + activeTab: "basic", + isPending: false, + showFailureThresholdConfirm: false, + }, + }; +} + +// Default initial state +const defaultInitialState: ProviderFormState = createInitialState("create"); + +// Reducer function +export function providerFormReducer( + state: ProviderFormState, + action: ProviderFormAction +): ProviderFormState { + switch (action.type) { + // Basic info + case "SET_NAME": + return { ...state, basic: { ...state.basic, name: action.payload } }; + case "SET_URL": + return { ...state, basic: { ...state.basic, url: action.payload } }; + case "SET_KEY": + return { ...state, basic: { ...state.basic, key: action.payload } }; + case "SET_WEBSITE_URL": + return { ...state, basic: { ...state.basic, websiteUrl: action.payload } }; + + // Routing + case "SET_PROVIDER_TYPE": + return { ...state, routing: { ...state.routing, providerType: action.payload } }; + case "SET_GROUP_TAG": + return { ...state, routing: { ...state.routing, groupTag: action.payload } }; + case "SET_PRESERVE_CLIENT_IP": + return { ...state, routing: { ...state.routing, preserveClientIp: action.payload } }; + case "SET_MODEL_REDIRECTS": + return { ...state, routing: { ...state.routing, modelRedirects: action.payload } }; + case "SET_ALLOWED_MODELS": + return { ...state, routing: { ...state.routing, allowedModels: action.payload } }; + case "SET_JOIN_CLAUDE_POOL": + return { ...state, routing: { ...state.routing, joinClaudePool: action.payload } }; + case "SET_PRIORITY": + return { ...state, routing: { ...state.routing, priority: action.payload } }; + case "SET_WEIGHT": + return { ...state, routing: { ...state.routing, weight: action.payload } }; + case "SET_COST_MULTIPLIER": + return { ...state, routing: { ...state.routing, costMultiplier: action.payload } }; + case "SET_CACHE_TTL_PREFERENCE": + return { ...state, routing: { ...state.routing, cacheTtlPreference: action.payload } }; + case "SET_CONTEXT_1M_PREFERENCE": + return { ...state, routing: { ...state.routing, context1mPreference: action.payload } }; + case "SET_CODEX_REASONING_EFFORT": + return { + ...state, + routing: { ...state.routing, codexReasoningEffortPreference: action.payload }, + }; + case "SET_CODEX_REASONING_SUMMARY": + return { + ...state, + routing: { ...state.routing, codexReasoningSummaryPreference: action.payload }, + }; + case "SET_CODEX_TEXT_VERBOSITY": + return { + ...state, + routing: { ...state.routing, codexTextVerbosityPreference: action.payload }, + }; + case "SET_CODEX_PARALLEL_TOOL_CALLS": + return { + ...state, + routing: { ...state.routing, codexParallelToolCallsPreference: action.payload }, + }; + + // Rate limit + case "SET_LIMIT_5H_USD": + return { ...state, rateLimit: { ...state.rateLimit, limit5hUsd: action.payload } }; + case "SET_LIMIT_DAILY_USD": + return { ...state, rateLimit: { ...state.rateLimit, limitDailyUsd: action.payload } }; + case "SET_DAILY_RESET_MODE": + return { ...state, rateLimit: { ...state.rateLimit, dailyResetMode: action.payload } }; + case "SET_DAILY_RESET_TIME": + return { ...state, rateLimit: { ...state.rateLimit, dailyResetTime: action.payload } }; + case "SET_LIMIT_WEEKLY_USD": + return { ...state, rateLimit: { ...state.rateLimit, limitWeeklyUsd: action.payload } }; + case "SET_LIMIT_MONTHLY_USD": + return { ...state, rateLimit: { ...state.rateLimit, limitMonthlyUsd: action.payload } }; + case "SET_LIMIT_TOTAL_USD": + return { ...state, rateLimit: { ...state.rateLimit, limitTotalUsd: action.payload } }; + case "SET_LIMIT_CONCURRENT_SESSIONS": + return { + ...state, + rateLimit: { ...state.rateLimit, limitConcurrentSessions: action.payload }, + }; + + // Circuit breaker + case "SET_FAILURE_THRESHOLD": + return { + ...state, + circuitBreaker: { ...state.circuitBreaker, failureThreshold: action.payload }, + }; + case "SET_OPEN_DURATION_MINUTES": + return { + ...state, + circuitBreaker: { ...state.circuitBreaker, openDurationMinutes: action.payload }, + }; + case "SET_HALF_OPEN_SUCCESS_THRESHOLD": + return { + ...state, + circuitBreaker: { ...state.circuitBreaker, halfOpenSuccessThreshold: action.payload }, + }; + case "SET_MAX_RETRY_ATTEMPTS": + return { + ...state, + circuitBreaker: { ...state.circuitBreaker, maxRetryAttempts: action.payload }, + }; + + // Network + case "SET_PROXY_URL": + return { ...state, network: { ...state.network, proxyUrl: action.payload } }; + case "SET_PROXY_FALLBACK_TO_DIRECT": + return { ...state, network: { ...state.network, proxyFallbackToDirect: action.payload } }; + case "SET_FIRST_BYTE_TIMEOUT_STREAMING": + return { + ...state, + network: { ...state.network, firstByteTimeoutStreamingSeconds: action.payload }, + }; + case "SET_STREAMING_IDLE_TIMEOUT": + return { + ...state, + network: { ...state.network, streamingIdleTimeoutSeconds: action.payload }, + }; + case "SET_REQUEST_TIMEOUT_NON_STREAMING": + return { + ...state, + network: { ...state.network, requestTimeoutNonStreamingSeconds: action.payload }, + }; + + // MCP + case "SET_MCP_PASSTHROUGH_TYPE": + return { ...state, mcp: { ...state.mcp, mcpPassthroughType: action.payload } }; + case "SET_MCP_PASSTHROUGH_URL": + return { ...state, mcp: { ...state.mcp, mcpPassthroughUrl: action.payload } }; + + // UI + case "SET_ACTIVE_TAB": + return { ...state, ui: { ...state.ui, activeTab: action.payload } }; + case "SET_IS_PENDING": + return { ...state, ui: { ...state.ui, isPending: action.payload } }; + case "SET_SHOW_FAILURE_THRESHOLD_CONFIRM": + return { ...state, ui: { ...state.ui, showFailureThresholdConfirm: action.payload } }; + + // Reset + case "RESET_FORM": + return { + ...defaultInitialState, + ui: { ...defaultInitialState.ui, activeTab: state.ui.activeTab }, + }; + + // Load provider data + case "LOAD_PROVIDER": + return createInitialState("edit", action.payload); + + default: + return state; + } +} + +// Context +const ProviderFormContext = createContext(null); + +// Provider component +export function ProviderFormProvider({ + children, + mode, + provider, + cloneProvider, + enableMultiProviderTypes, + hideUrl = false, + hideWebsiteUrl = false, + preset, + groupSuggestions, +}: { + children: ReactNode; + mode: FormMode; + provider?: ProviderDisplay; + cloneProvider?: ProviderDisplay; + enableMultiProviderTypes: boolean; + hideUrl?: boolean; + hideWebsiteUrl?: boolean; + preset?: { + name?: string; + url?: string; + websiteUrl?: string; + providerType?: "claude" | "claude-auth" | "codex" | "gemini" | "gemini-cli" | "openai-compatible"; + }; + groupSuggestions: string[]; +}) { + const [state, dispatch] = useReducer( + providerFormReducer, + createInitialState(mode, provider, cloneProvider, preset) + ); + + return ( + + {children} + + ); +} + +// Hook +export function useProviderForm(): ProviderFormContextValue { + const context = useContext(ProviderFormContext); + if (!context) { + throw new Error("useProviderForm must be used within a ProviderFormProvider"); + } + return context; +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts new file mode 100644 index 000000000..a7feaf616 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -0,0 +1,184 @@ +import type { Context1mPreference } from "@/lib/special-attributes"; +import type { + CodexInstructionsStrategy, + CodexParallelToolCallsPreference, + CodexReasoningEffortPreference, + CodexReasoningSummaryPreference, + CodexTextVerbosityPreference, + McpPassthroughType, + ProviderDisplay, + ProviderType, +} from "@/types/provider"; + +// Form mode +export type FormMode = "create" | "edit"; + +// Tab identifiers +export type TabId = "basic" | "routing" | "limits" | "network" | "testing"; + +// Tab configuration +export interface TabConfig { + id: TabId; + labelKey: string; + icon: string; +} + +// Form state sections +export interface BasicInfoState { + name: string; + url: string; + key: string; + websiteUrl: string; +} + +export interface RoutingState { + providerType: ProviderType; + groupTag: string[]; + preserveClientIp: boolean; + modelRedirects: Record; + allowedModels: string[]; + joinClaudePool: boolean; + priority: number; + weight: number; + costMultiplier: number; + cacheTtlPreference: "inherit" | "5m" | "1h"; + context1mPreference: "inherit" | "force_enable" | "disabled"; + // Codex-specific + codexReasoningEffortPreference: CodexReasoningEffortPreference; + codexReasoningSummaryPreference: CodexReasoningSummaryPreference; + codexTextVerbosityPreference: CodexTextVerbosityPreference; + codexParallelToolCallsPreference: CodexParallelToolCallsPreference; +} + +export interface RateLimitState { + limit5hUsd: number | null; + limitDailyUsd: number | null; + dailyResetMode: "fixed" | "rolling"; + dailyResetTime: string; + limitWeeklyUsd: number | null; + limitMonthlyUsd: number | null; + limitTotalUsd: number | null; + limitConcurrentSessions: number | null; +} + +export interface CircuitBreakerState { + failureThreshold: number | undefined; + openDurationMinutes: number | undefined; + halfOpenSuccessThreshold: number | undefined; + maxRetryAttempts: number | null; +} + +export interface NetworkState { + proxyUrl: string; + proxyFallbackToDirect: boolean; + firstByteTimeoutStreamingSeconds: number | undefined; + streamingIdleTimeoutSeconds: number | undefined; + requestTimeoutNonStreamingSeconds: number | undefined; +} + +export interface McpState { + mcpPassthroughType: McpPassthroughType; + mcpPassthroughUrl: string; +} + +export interface UIState { + activeTab: TabId; + isPending: boolean; + showFailureThresholdConfirm: boolean; +} + +// Complete form state +export interface ProviderFormState { + basic: BasicInfoState; + routing: RoutingState; + rateLimit: RateLimitState; + circuitBreaker: CircuitBreakerState; + network: NetworkState; + mcp: McpState; + ui: UIState; +} + +// Action types for reducer +export type ProviderFormAction = + // Basic info actions + | { type: "SET_NAME"; payload: string } + | { type: "SET_URL"; payload: string } + | { type: "SET_KEY"; payload: string } + | { type: "SET_WEBSITE_URL"; payload: string } + // Routing actions + | { type: "SET_PROVIDER_TYPE"; payload: ProviderType } + | { type: "SET_GROUP_TAG"; payload: string[] } + | { type: "SET_PRESERVE_CLIENT_IP"; payload: boolean } + | { type: "SET_MODEL_REDIRECTS"; payload: Record } + | { type: "SET_ALLOWED_MODELS"; payload: string[] } + | { type: "SET_JOIN_CLAUDE_POOL"; payload: boolean } + | { type: "SET_PRIORITY"; payload: number } + | { type: "SET_WEIGHT"; payload: number } + | { type: "SET_COST_MULTIPLIER"; payload: number } + | { type: "SET_CACHE_TTL_PREFERENCE"; payload: "inherit" | "5m" | "1h" } + | { type: "SET_CONTEXT_1M_PREFERENCE"; payload: "inherit" | "force_enable" | "disabled" } + | { type: "SET_CODEX_REASONING_EFFORT"; payload: CodexReasoningEffortPreference } + | { type: "SET_CODEX_REASONING_SUMMARY"; payload: CodexReasoningSummaryPreference } + | { type: "SET_CODEX_TEXT_VERBOSITY"; payload: CodexTextVerbosityPreference } + | { type: "SET_CODEX_PARALLEL_TOOL_CALLS"; payload: CodexParallelToolCallsPreference } + // Rate limit actions + | { type: "SET_LIMIT_5H_USD"; payload: number | null } + | { type: "SET_LIMIT_DAILY_USD"; payload: number | null } + | { type: "SET_DAILY_RESET_MODE"; payload: "fixed" | "rolling" } + | { type: "SET_DAILY_RESET_TIME"; payload: string } + | { type: "SET_LIMIT_WEEKLY_USD"; payload: number | null } + | { type: "SET_LIMIT_MONTHLY_USD"; payload: number | null } + | { type: "SET_LIMIT_TOTAL_USD"; payload: number | null } + | { type: "SET_LIMIT_CONCURRENT_SESSIONS"; payload: number | null } + // Circuit breaker actions + | { type: "SET_FAILURE_THRESHOLD"; payload: number | undefined } + | { type: "SET_OPEN_DURATION_MINUTES"; payload: number | undefined } + | { type: "SET_HALF_OPEN_SUCCESS_THRESHOLD"; payload: number | undefined } + | { type: "SET_MAX_RETRY_ATTEMPTS"; payload: number | null } + // Network actions + | { type: "SET_PROXY_URL"; payload: string } + | { type: "SET_PROXY_FALLBACK_TO_DIRECT"; payload: boolean } + | { type: "SET_FIRST_BYTE_TIMEOUT_STREAMING"; payload: number | undefined } + | { type: "SET_STREAMING_IDLE_TIMEOUT"; payload: number | undefined } + | { type: "SET_REQUEST_TIMEOUT_NON_STREAMING"; payload: number | undefined } + // MCP actions + | { type: "SET_MCP_PASSTHROUGH_TYPE"; payload: McpPassthroughType } + | { type: "SET_MCP_PASSTHROUGH_URL"; payload: string } + // UI actions + | { type: "SET_ACTIVE_TAB"; payload: TabId } + | { type: "SET_IS_PENDING"; payload: boolean } + | { type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM"; payload: boolean } + // Bulk actions + | { type: "RESET_FORM" } + | { type: "LOAD_PROVIDER"; payload: ProviderDisplay }; + +// Form props +export interface ProviderFormProps { + mode: FormMode; + onSuccess?: () => void; + provider?: ProviderDisplay; + cloneProvider?: ProviderDisplay; + enableMultiProviderTypes: boolean; + hideUrl?: boolean; + hideWebsiteUrl?: boolean; + preset?: { + name?: string; + url?: string; + websiteUrl?: string; + providerType?: ProviderType; + }; + urlResolver?: (providerType: ProviderType) => Promise; + allowedProviderTypes?: ProviderType[]; +} + +// Context value +export interface ProviderFormContextValue { + state: ProviderFormState; + dispatch: React.Dispatch; + mode: FormMode; + provider?: ProviderDisplay; + enableMultiProviderTypes: boolean; + hideUrl: boolean; + hideWebsiteUrl: boolean; + groupSuggestions: string[]; +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx new file mode 100644 index 000000000..32fb7efb2 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ExternalLink, Eye, EyeOff, Globe, Key, Link2, User } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { UrlPreview } from "../../url-preview"; +import { useProviderForm } from "../provider-form-context"; +import { SectionCard, SmartInputWrapper } from "../components/section-card"; + +interface BasicInfoSectionProps { + autoUrlPending?: boolean; +} + +export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) { + const t = useTranslations("settings.providers.form"); + const tProviders = useTranslations("settings.providers"); + const { state, dispatch, mode, provider, hideUrl, hideWebsiteUrl } = useProviderForm(); + const isEdit = mode === "edit"; + const nameInputRef = useRef(null); + const [showKey, setShowKey] = useState(false); + + // Auto-focus name input + useEffect(() => { + const timer = setTimeout(() => { + nameInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + }, []); + + return ( + + {/* Provider Identity */} + +
+ +
+ dispatch({ type: "SET_NAME", payload: e.target.value })} + placeholder={t("name.placeholder")} + disabled={state.ui.isPending} + className="pr-10" + /> + +
+
+
+
+ + {/* API Endpoint */} + {!hideUrl ? ( + +
+ +
+ dispatch({ type: "SET_URL", payload: e.target.value })} + placeholder={t("url.placeholder")} + disabled={state.ui.isPending} + className="pr-10 font-mono text-sm" + /> + +
+
+ + {/* URL Preview */} + {state.basic.url.trim() && ( + + + + )} +
+
+ ) : ( + <> + {/* No endpoints warning */} + {!isEdit && !autoUrlPending && !state.basic.url.trim() && ( + +
{tProviders("noEndpoints")}
+
+ {tProviders("noEndpointsDesc")} +
+
+ )} + {/* Loading state */} + {!isEdit && autoUrlPending && ( +
+ {tProviders("keyLoading")} +
+ )} + + )} + + {/* Authentication */} + +
+ +
+ dispatch({ type: "SET_KEY", payload: e.target.value })} + placeholder={isEdit ? t("key.leaveEmptyDesc") : t("key.placeholder")} + disabled={state.ui.isPending} + className="pr-10 font-mono text-sm" + /> + +
+
+
+
+ + {/* Website URL */} + {!hideWebsiteUrl && ( + + +
+ dispatch({ type: "SET_WEBSITE_URL", payload: e.target.value })} + placeholder={t("websiteUrl.placeholder")} + disabled={state.ui.isPending} + className="pr-10" + /> + +
+
+
+ )} +
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx new file mode 100644 index 000000000..c0db9a9db --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -0,0 +1,429 @@ +"use client"; + +import { motion } from "framer-motion"; +import { + AlertTriangle, + Clock, + DollarSign, + Gauge, + RefreshCw, + Shield, + Users, + Zap, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { PROVIDER_DEFAULTS } from "@/lib/constants/provider.constants"; +import { cn } from "@/lib/utils"; +import { useProviderForm } from "../provider-form-context"; +import { FieldGroup, SectionCard, SmartInputWrapper } from "../components/section-card"; + +// Validation helpers +function validatePositiveDecimalField(value: string): number | null { + if (value === "") return null; + const num = parseFloat(value); + if (Number.isNaN(num) || num < 0) return null; + return num; +} + +function validateNumericField(value: string): number | null { + if (value === "") return null; + const num = parseInt(value, 10); + if (Number.isNaN(num) || num < 0) return null; + return num; +} + +// Visual limit card component +interface LimitCardProps { + label: string; + value: number | null; + unit: string; + icon: React.ElementType; + color: string; + id: string; + placeholder: string; + onChange: (value: number | null) => void; + disabled?: boolean; + step?: string; + min?: string; + isDecimal?: boolean; +} + +function LimitCard({ + label, + value, + unit, + icon: Icon, + color, + id, + placeholder, + onChange, + disabled, + step = "0.01", + min = "0", + isDecimal = true, +}: LimitCardProps) { + return ( +
+
+ + + +
+ +
+ + onChange( + isDecimal + ? validatePositiveDecimalField(e.target.value) + : validateNumericField(e.target.value) + ) + } + placeholder={placeholder} + disabled={disabled} + min={min} + step={step} + className="pr-12 font-mono" + /> + + {unit} + +
+
+
+ {value !== null && ( + + )} +
+ ); +} + +export function LimitsSection() { + const t = useTranslations("settings.providers.form"); + const { state, dispatch, mode } = useProviderForm(); + const isEdit = mode === "edit"; + + return ( + + {/* USD Spending Limits */} + +
+ {/* Time-based limits grid */} + +
+ dispatch({ type: "SET_LIMIT_5H_USD", payload: value })} + disabled={state.ui.isPending} + /> + dispatch({ type: "SET_LIMIT_DAILY_USD", payload: value })} + disabled={state.ui.isPending} + /> + dispatch({ type: "SET_LIMIT_WEEKLY_USD", payload: value })} + disabled={state.ui.isPending} + /> + dispatch({ type: "SET_LIMIT_MONTHLY_USD", payload: value })} + disabled={state.ui.isPending} + /> +
+
+ + {/* Daily Reset Settings */} + +
+ + + + + {state.rateLimit.dailyResetMode === "fixed" && ( + + + dispatch({ type: "SET_DAILY_RESET_TIME", payload: e.target.value || "00:00" }) + } + placeholder="00:00" + disabled={state.ui.isPending} + step="60" + /> + + )} +
+
+ + {/* Total and Concurrent Limits */} + +
+ dispatch({ type: "SET_LIMIT_TOTAL_USD", payload: value })} + disabled={state.ui.isPending} + /> + + dispatch({ type: "SET_LIMIT_CONCURRENT_SESSIONS", payload: value }) + } + disabled={state.ui.isPending} + step="1" + isDecimal={false} + /> +
+
+
+
+ + {/* Circuit Breaker Settings */} + +
+ {/* Circuit Breaker Parameters */} +
+ +
+ { + const val = e.target.value; + dispatch({ + type: "SET_FAILURE_THRESHOLD", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + className={cn( + state.circuitBreaker.failureThreshold === 0 && "border-yellow-500" + )} + /> + +
+ {state.circuitBreaker.failureThreshold === 0 && ( +

+ {t("sections.circuitBreaker.failureThreshold.warning")} +

+ )} +
+ + +
+ { + const val = e.target.value; + dispatch({ + type: "SET_OPEN_DURATION_MINUTES", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.openDuration.placeholder")} + disabled={state.ui.isPending} + min="1" + max="1440" + step="1" + className="pr-12" + /> + + min + +
+
+ + + { + const val = e.target.value; + dispatch({ + type: "SET_HALF_OPEN_SUCCESS_THRESHOLD", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.successThreshold.placeholder")} + disabled={state.ui.isPending} + min="1" + max="10" + step="1" + /> + + + +
+ { + const val = e.target.value; + dispatch({ + type: "SET_MAX_RETRY_ATTEMPTS", + payload: val === "" ? null : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.maxRetryAttempts.placeholder")} + disabled={state.ui.isPending} + min="1" + max="10" + step="1" + /> + +
+
+
+ + {/* Circuit Breaker Status Indicator */} +
+ +
+ {t("sections.circuitBreaker.summary", { + failureThreshold: state.circuitBreaker.failureThreshold ?? 5, + openDuration: state.circuitBreaker.openDurationMinutes ?? 30, + successThreshold: state.circuitBreaker.halfOpenSuccessThreshold ?? 2, + maxRetryAttempts: + state.circuitBreaker.maxRetryAttempts ?? PROVIDER_DEFAULTS.MAX_RETRY_ATTEMPTS, + })} +
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx new file mode 100644 index 000000000..658c8bdb5 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Clock, Globe, Network, Shield, Timer, Wifi } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; +import { cn } from "@/lib/utils"; +import { ProxyTestButton } from "../../proxy-test-button"; +import { useProviderForm } from "../provider-form-context"; +import { FieldGroup, SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; + +// Timeout input component with visual indicator +interface TimeoutInputProps { + id: string; + label: string; + description: string; + value: number | undefined; + defaultValue: number; + placeholder: string; + onChange: (value: number | undefined) => void; + disabled?: boolean; + min?: string; + max?: string; + icon: React.ElementType; + isCore?: boolean; +} + +function TimeoutInput({ + id, + label, + description, + value, + defaultValue, + placeholder, + onChange, + disabled, + min = "0", + max, + icon: Icon, + isCore, +}: TimeoutInputProps) { + const t = useTranslations("settings.providers.form"); + const displayValue = value ?? defaultValue; + const isCustom = value !== undefined; + + return ( +
+
+ + + +
+
+ + {isCore && ( + + {t("common.core")} + + )} +
+
+ { + const val = e.target.value; + onChange(val === "" ? undefined : parseInt(val, 10)); + }} + placeholder={placeholder} + disabled={disabled} + min={min} + max={max} + step="1" + className={cn("pr-8 font-mono", isCore && "border-orange-200 focus:border-orange-500")} + /> + + s + +
+

{description}

+
+
+ {isCustom && ( + + )} +
+ ); +} + +export function NetworkSection() { + const t = useTranslations("settings.providers.form"); + const { state, dispatch, mode } = useProviderForm(); + const isEdit = mode === "edit"; + + return ( + + {/* Proxy Configuration */} + +
+ +
+ dispatch({ type: "SET_PROXY_URL", payload: e.target.value })} + placeholder={t("sections.proxy.url.placeholder")} + disabled={state.ui.isPending} + className="pr-10 font-mono text-sm" + /> + +
+
+ + {state.network.proxyUrl && ( + + + + dispatch({ type: "SET_PROXY_FALLBACK_TO_DIRECT", payload: checked }) + } + disabled={state.ui.isPending} + /> + + + {/* Proxy Test */} +
+
+ +
+
{t("sections.proxy.test.label")}
+

{t("sections.proxy.test.desc")}

+
+
+ +
+
+ )} +
+
+ + {/* Timeout Configuration */} + +
+ +
+ + dispatch({ type: "SET_FIRST_BYTE_TIMEOUT_STREAMING", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="180" + icon={Clock} + isCore={true} + /> + + + dispatch({ type: "SET_STREAMING_IDLE_TIMEOUT", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="600" + icon={Timer} + isCore={true} + /> + + + dispatch({ type: "SET_REQUEST_TIMEOUT_NON_STREAMING", payload: value }) + } + disabled={state.ui.isPending} + min="60" + max="1200" + icon={Clock} + isCore={true} + /> +
+
+ + {/* Timeout Summary */} +
+ +
+ {t("sections.timeout.summary", { + streaming: + state.network.firstByteTimeoutStreamingSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS / 1000, + idle: + state.network.streamingIdleTimeoutSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS / 1000, + nonStreaming: + state.network.requestTimeoutNonStreamingSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS / 1000, + })} +
+
+ +

{t("sections.timeout.disableHint")}

+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx new file mode 100644 index 000000000..77c49cfdf --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Info, Layers, Route, Scale, Settings, Timer, Users } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { TagInput } from "@/components/ui/tag-input"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { getProviderTypeConfig } from "@/lib/provider-type-utils"; +import type { + CodexParallelToolCallsPreference, + CodexReasoningEffortPreference, + CodexReasoningSummaryPreference, + CodexTextVerbosityPreference, + ProviderType, +} from "@/types/provider"; +import { ModelMultiSelect } from "../../../model-multi-select"; +import { ModelRedirectEditor } from "../../../model-redirect-editor"; +import { useProviderForm } from "../provider-form-context"; +import { FieldGroup, SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; + +const GROUP_TAG_MAX_TOTAL_LENGTH = 50; + +export function RoutingSection() { + const t = useTranslations("settings.providers.form"); + const tUI = useTranslations("ui.tagInput"); + const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions } = + useProviderForm(); + const isEdit = mode === "edit"; + + const renderProviderTypeLabel = (type: ProviderType) => { + switch (type) { + case "claude": + return t("providerTypes.claude"); + case "codex": + return t("providerTypes.codex"); + case "gemini": + return t("providerTypes.gemini"); + case "openai-compatible": + return t("providerTypes.openaiCompatible"); + default: + return type; + } + }; + + const handleGroupTagChange = (nextTags: string[]) => { + const serialized = nextTags.join(","); + if (serialized.length > GROUP_TAG_MAX_TOTAL_LENGTH) { + toast.error(t("errors.groupTagTooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH })); + return; + } + dispatch({ type: "SET_GROUP_TAG", payload: nextTags }); + }; + + const hasClaudeRedirects = Object.values(state.routing.modelRedirects).some((target) => + target.startsWith("claude-") + ); + + const providerTypes: ProviderType[] = [ + "claude", + "codex", + "gemini", + "openai-compatible", + ]; + + return ( + + + {/* Provider Type & Group */} + +
+ + + {!enableMultiProviderTypes && state.routing.providerType === "openai-compatible" && ( +

{t("sections.routing.providerTypeDisabledNote")}

+ )} +
+ + + { + const messages: Record = { + empty: tUI("emptyTag"), + duplicate: tUI("duplicateTag"), + too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }), + invalid_format: tUI("invalidFormat"), + max_tags: tUI("maxTags"), + }; + toast.error(messages[reason] || reason); + }} + /> + +
+
+ + {/* Model Configuration */} + +
+ {/* Model Redirects */} + + ) => dispatch({ type: "SET_MODEL_REDIRECTS", payload: value })} + disabled={state.ui.isPending} + /> + + + {/* Join Claude Pool */} + {state.routing.providerType !== "claude" && hasClaudeRedirects && ( + + + dispatch({ type: "SET_JOIN_CLAUDE_POOL", payload: checked }) + } + disabled={state.ui.isPending} + /> + + )} + + {/* Allowed Models */} + + dispatch({ type: "SET_ALLOWED_MODELS", payload: value })} + disabled={state.ui.isPending} + providerUrl={state.basic.url} + apiKey={state.basic.key} + proxyUrl={state.network.proxyUrl} + proxyFallbackToDirect={state.network.proxyFallbackToDirect} + providerId={isEdit ? provider?.id : undefined} + /> + {state.routing.allowedModels.length > 0 && ( +
+ {state.routing.allowedModels.slice(0, 5).map((model) => ( + + {model} + + ))} + {state.routing.allowedModels.length > 5 && ( + + {t("sections.routing.modelWhitelist.moreModels", { + count: state.routing.allowedModels.length - 5, + })} + + )} +
+ )} +

+ {state.routing.allowedModels.length === 0 ? ( + {t("sections.routing.modelWhitelist.allowAll")} + ) : ( + + {t("sections.routing.modelWhitelist.selectedOnly", { + count: state.routing.allowedModels.length, + })} + + )} +

+
+
+
+ + {/* Scheduling Parameters */} + +
+ + + dispatch({ type: "SET_PRIORITY", payload: parseInt(e.target.value, 10) || 0 }) + } + placeholder={t("sections.routing.scheduleParams.priority.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + /> + + + + + dispatch({ type: "SET_WEIGHT", payload: parseInt(e.target.value, 10) || 1 }) + } + placeholder={t("sections.routing.scheduleParams.weight.placeholder")} + disabled={state.ui.isPending} + min="1" + step="1" + /> + + + + { + const value = e.target.value; + if (value === "") { + dispatch({ type: "SET_COST_MULTIPLIER", payload: 1.0 }); + return; + } + const num = parseFloat(value); + dispatch({ type: "SET_COST_MULTIPLIER", payload: Number.isNaN(num) ? 1.0 : num }); + }} + onFocus={(e) => e.target.select()} + placeholder={t("sections.routing.scheduleParams.costMultiplier.placeholder")} + disabled={state.ui.isPending} + min="0" + step="0.0001" + /> + +
+
+ + {/* Advanced Settings */} + +
+ + + dispatch({ type: "SET_PRESERVE_CLIENT_IP", payload: checked }) + } + disabled={state.ui.isPending} + /> + + + {/* Cache TTL */} + + + + + {/* 1M Context Window - Claude type only */} + {state.routing.providerType === "claude" && ( + + + + )} +
+
+ + {/* Codex Overrides - Codex type only */} + {state.routing.providerType === "codex" && ( + +
+ + + +
+ + +
+
+ +

{t("sections.routing.codexOverrides.reasoningEffort.help")}

+
+
+
+ + + + + + + + + + + + +
+
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx new file mode 100644 index 000000000..8688a333c --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { motion } from "framer-motion"; +import { FlaskConical, Globe, Link2, Plug, Zap } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { extractBaseUrl } from "@/lib/utils/validation"; +import type { McpPassthroughType } from "@/types/provider"; +import { ApiTestButton } from "../../api-test-button"; +import { useProviderForm } from "../provider-form-context"; +import { SectionCard, SmartInputWrapper } from "../components/section-card"; + +export function TestingSection() { + const t = useTranslations("settings.providers.form"); + const { state, dispatch, mode, provider, enableMultiProviderTypes } = useProviderForm(); + const isEdit = mode === "edit"; + + return ( + + {/* API Test */} + +
+ {/* Test Summary */} +
+ +
+ {t("sections.apiTest.summary")} +
+
+ + {/* API Test Button */} +
+
+ + + +
+
{t("sections.apiTest.testLabel")}
+

{t("sections.apiTest.desc")}

+
+
+ +
+
+
+ + {/* MCP Passthrough */} + +
+ + + + + {/* MCP Passthrough URL - shown when not "none" */} + {state.mcp.mcpPassthroughType !== "none" && ( + + +
+ + dispatch({ type: "SET_MCP_PASSTHROUGH_URL", payload: e.target.value }) + } + placeholder={t("sections.mcpPassthrough.urlPlaceholder")} + disabled={state.ui.isPending} + className="pr-10 font-mono text-sm" + /> + +
+ {!state.mcp.mcpPassthroughUrl && state.basic.url && ( +

+ {t("sections.mcpPassthrough.urlAuto", { + url: extractBaseUrl(state.basic.url), + })} +

+ )} +
+
+ )} + + {/* MCP Status Summary */} +
+ +
+ {state.mcp.mcpPassthroughType === "none" && t("sections.mcpPassthrough.summary.none")} + {state.mcp.mcpPassthroughType === "minimax" && + t("sections.mcpPassthrough.summary.minimax")} + {state.mcp.mcpPassthroughType === "glm" && t("sections.mcpPassthrough.summary.glm")} + {state.mcp.mcpPassthroughType === "custom" && + t("sections.mcpPassthrough.summary.custom")} +
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx index fd666d59e..de4d40688 100644 --- a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx @@ -256,7 +256,7 @@ function VendorEndpointsSection({ vendorId }: { vendorId: number }) { const tTypes = useTranslations("settings.providers.types"); const [activeType, setActiveType] = useState("claude"); - const providerTypes: ProviderType[] = ["claude", "claude-auth", "codex", "gemini", "gemini-cli"]; + const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; return (
diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index 3f21ec781..256d81bf8 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -79,10 +79,9 @@ export function VendorKeysCompactList(props: { const defaultProviderType: ProviderType = props.providers[0]?.providerType ?? "claude"; const vendorAllowedTypes: ProviderType[] = [ "claude", - "claude-auth", "codex", "gemini", - "gemini-cli", + "openai-compatible", ]; const statistics = props.statistics ?? {}; const statisticsLoading = props.statisticsLoading ?? false; From 16bb04b2f5c1232a2f03c153bd8d41d3d1027c58 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 21 Jan 2026 21:17:38 +0000 Subject: [PATCH 02/18] chore: format code (feat-provider-form-refactor-6bc06d5) --- .../provider-form/components/form-tab-nav.tsx | 20 +----- .../provider-form/components/section-card.tsx | 34 ++------- .../_components/forms/provider-form/index.tsx | 36 ++++++++-- .../provider-form/provider-form-context.tsx | 16 ++++- .../sections/basic-info-section.tsx | 14 +--- .../provider-form/sections/limits-section.tsx | 9 +-- .../sections/network-section.tsx | 5 +- .../sections/routing-section.tsx | 71 ++++++++++++------- .../sections/testing-section.tsx | 8 ++- .../_components/vendor-keys-compact-list.tsx | 7 +- 10 files changed, 114 insertions(+), 106 deletions(-) diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx index 143ea95a1..93471e0c1 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx @@ -1,13 +1,7 @@ "use client"; import { motion } from "framer-motion"; -import { - FileText, - FlaskConical, - Gauge, - Network, - Route, -} from "lucide-react"; +import { FileText, FlaskConical, Gauge, Network, Route } from "lucide-react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { TabId } from "../provider-form-types"; @@ -27,12 +21,7 @@ interface FormTabNavProps { tabStatus?: Partial>; } -export function FormTabNav({ - activeTab, - onTabChange, - disabled, - tabStatus = {}, -}: FormTabNavProps) { +export function FormTabNav({ activeTab, onTabChange, disabled, tabStatus = {} }: FormTabNavProps) { const t = useTranslations("settings.providers.form"); const getStatusColor = (status?: "default" | "warning" | "configured") => { @@ -79,10 +68,7 @@ export function FormTabNav({ )} > {t(tab.labelKey)} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx index d51e9d8f1..8c97b8dd3 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/section-card.tsx @@ -72,23 +72,17 @@ export function SectionCard({ )}
{title && ( -

- {title} -

+

{title}

)} {description && ( -

- {description} -

+

{description}

)}
{badge}
)} -
- {children} -
+
{children}
); @@ -114,23 +108,11 @@ export function FieldGroup({
{(label || description) && (
- {label && ( -
{label}
- )} - {description && ( -

{description}

- )} + {label &&
{label}
} + {description &&

{description}

}
)} -
- {children} -
+
{children}
); } @@ -211,9 +193,7 @@ export function ToggleRow({ )}
{label}
- {description && ( -

{description}

- )} + {description &&

{description}

}
{children} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 9b19c56ea..c419af818 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -185,7 +185,8 @@ function ProviderFormContent({ provider_type: state.routing.providerType, preserve_client_ip: state.routing.preserveClientIp, model_redirects: state.routing.modelRedirects, - allowed_models: state.routing.allowedModels.length > 0 ? state.routing.allowedModels : null, + allowed_models: + state.routing.allowedModels.length > 0 ? state.routing.allowedModels : null, join_claude_pool: state.routing.joinClaudePool, priority: state.routing.priority, weight: state.routing.weight, @@ -207,7 +208,8 @@ function ProviderFormContent({ limit_concurrent_sessions: state.rateLimit.limitConcurrentSessions, circuit_breaker_failure_threshold: state.circuitBreaker.failureThreshold, circuit_breaker_open_duration: openDurationMs, - circuit_breaker_half_open_success_threshold: state.circuitBreaker.halfOpenSuccessThreshold, + circuit_breaker_half_open_success_threshold: + state.circuitBreaker.halfOpenSuccessThreshold, max_retry_attempts: state.circuitBreaker.maxRetryAttempts, proxy_url: state.network.proxyUrl?.trim() || null, proxy_fallback_to_direct: state.network.proxyFallbackToDirect, @@ -343,27 +345,47 @@ function ProviderFormContent({ >
{/* Basic Info Section */} -
{ sectionRefs.current.basic = el; }}> +
{ + sectionRefs.current.basic = el; + }} + >
{/* Routing Section */} -
{ sectionRefs.current.routing = el; }}> +
{ + sectionRefs.current.routing = el; + }} + >
{/* Limits Section */} -
{ sectionRefs.current.limits = el; }}> +
{ + sectionRefs.current.limits = el; + }} + >
{/* Network Section */} -
{ sectionRefs.current.network = el; }}> +
{ + sectionRefs.current.network = el; + }} + >
{/* Testing Section */} -
{ sectionRefs.current.testing = el; }}> +
{ + sectionRefs.current.testing = el; + }} + >
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index c1c4a7e10..a376dc25c 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -19,7 +19,13 @@ export function createInitialState( name?: string; url?: string; websiteUrl?: string; - providerType?: "claude" | "claude-auth" | "codex" | "gemini" | "gemini-cli" | "openai-compatible"; + providerType?: + | "claude" + | "claude-auth" + | "codex" + | "gemini" + | "gemini-cli" + | "openai-compatible"; } ): ProviderFormState { const isEdit = mode === "edit"; @@ -290,7 +296,13 @@ export function ProviderFormProvider({ name?: string; url?: string; websiteUrl?: string; - providerType?: "claude" | "claude-auth" | "codex" | "gemini" | "gemini-cli" | "openai-compatible"; + providerType?: + | "claude" + | "claude-auth" + | "codex" + | "gemini" + | "gemini-cli" + | "openai-compatible"; }; groupSuggestions: string[]; }) { diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx index 32fb7efb2..eb3798356 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx @@ -45,10 +45,7 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) { variant="highlight" >
- +
- +
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx index c0db9a9db..3ebd42f9e 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -80,10 +80,7 @@ function LimitCard({ >
@@ -310,9 +307,7 @@ export function LimitsSection() { disabled={state.ui.isPending} min="0" step="1" - className={cn( - state.circuitBreaker.failureThreshold === 0 && "border-yellow-500" - )} + className={cn(state.circuitBreaker.failureThreshold === 0 && "border-yellow-500")} /> s diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index 77c49cfdf..51feeba45 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -67,12 +67,7 @@ export function RoutingSection() { target.startsWith("claude-") ); - const providerTypes: ProviderType[] = [ - "claude", - "codex", - "gemini", - "openai-compatible", - ]; + const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; return ( @@ -123,7 +118,9 @@ export function RoutingSection() { {!enableMultiProviderTypes && state.routing.providerType === "openai-compatible" && ( -

{t("sections.routing.providerTypeDisabledNote")}

+

+ {t("sections.routing.providerTypeDisabledNote")} +

)} @@ -165,7 +162,9 @@ export function RoutingSection() { ) => dispatch({ type: "SET_MODEL_REDIRECTS", payload: value })} + onChange={(value: Record) => + dispatch({ type: "SET_MODEL_REDIRECTS", payload: value }) + } disabled={state.ui.isPending} /> @@ -194,7 +193,9 @@ export function RoutingSection() { dispatch({ type: "SET_ALLOWED_MODELS", payload: value })} + onChange={(value: string[]) => + dispatch({ type: "SET_ALLOWED_MODELS", payload: value }) + } disabled={state.ui.isPending} providerUrl={state.basic.url} apiKey={state.basic.key} @@ -220,7 +221,9 @@ export function RoutingSection() { )}

{state.routing.allowedModels.length === 0 ? ( - {t("sections.routing.modelWhitelist.allowAll")} + + {t("sections.routing.modelWhitelist.allowAll")} + ) : ( {t("sections.routing.modelWhitelist.selectedOnly", { @@ -304,10 +307,7 @@ export function RoutingSection() { {/* Advanced Settings */} - +

- dispatch({ type: "SET_CACHE_TTL_PREFERENCE", payload: val as "inherit" | "5m" | "1h" }) + dispatch({ + type: "SET_CACHE_TTL_PREFERENCE", + payload: val as "inherit" | "5m" | "1h", + }) } disabled={state.ui.isPending} > @@ -339,7 +342,9 @@ export function RoutingSection() { - {t("sections.routing.cacheTtl.options.inherit")} + + {t("sections.routing.cacheTtl.options.inherit")} + {t("sections.routing.cacheTtl.options.5m")} {t("sections.routing.cacheTtl.options.1h")} @@ -366,11 +371,15 @@ export function RoutingSection() { - {t("sections.routing.context1m.options.inherit")} + + {t("sections.routing.context1m.options.inherit")} + {t("sections.routing.context1m.options.forceEnable")} - {t("sections.routing.context1m.options.disabled")} + + {t("sections.routing.context1m.options.disabled")} + @@ -404,23 +413,31 @@ export function RoutingSection() { - {["inherit", "minimal", "low", "medium", "high", "xhigh", "none"].map((val) => ( - - {t(`sections.routing.codexOverrides.reasoningEffort.options.${val}`)} - - ))} + {["inherit", "minimal", "low", "medium", "high", "xhigh", "none"].map( + (val) => ( + + {t( + `sections.routing.codexOverrides.reasoningEffort.options.${val}` + )} + + ) + )}
-

{t("sections.routing.codexOverrides.reasoningEffort.help")}

+

+ {t("sections.routing.codexOverrides.reasoningEffort.help")} +

- + - + onCheckedChange(e.target.checked)} + disabled={disabled} + onClick={(e) => e.stopPropagation()} + /> +
+ +
+ ); +} + +// Input field component +interface SettingsInputProps { + label?: string; + description?: string; + value: string | number; + onChange: (value: string) => void; + type?: "text" | "number" | "password" | "url"; + placeholder?: string; + prefix?: string; + suffix?: string; + disabled?: boolean; + className?: string; + mono?: boolean; +} + +export function SettingsInput({ + label, + description, + value, + onChange, + type = "text", + placeholder, + prefix, + suffix, + disabled, + className, + mono, +}: SettingsInputProps) { + return ( +
+ {label && ( + + )} +
+ {prefix && ( + + {prefix} + + )} + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className={cn( + "w-full bg-muted/50 border border-border rounded-lg py-2 px-3 text-sm text-foreground", + "placeholder:text-muted-foreground/50", + "focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all", + "disabled:opacity-50 disabled:cursor-not-allowed", + prefix && "pl-7", + suffix && "pr-12", + mono && "font-mono" + )} + /> + {suffix && ( + + {suffix} + + )} +
+ {description &&

{description}

} +
+ ); +} + +// Select field component +interface SettingsSelectProps { + label?: string; + description?: string; + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; + disabled?: boolean; + className?: string; +} + +export function SettingsSelect({ + label, + description, + value, + onChange, + options, + disabled, + className, +}: SettingsSelectProps) { + return ( +
+ {label && ( + + )} +
+ + + + +
+ {description &&

{description}

} +
+ ); +} + +// Slider component +interface SettingsSliderProps { + label?: string; + description?: string; + value: number; + onChange: (value: number) => void; + min: number; + max: number; + step?: number; + disabled?: boolean; + className?: string; + formatValue?: (value: number) => string; + marks?: { value: number; label: string }[]; +} + +export function SettingsSlider({ + label, + description, + value, + onChange, + min, + max, + step = 1, + disabled, + className, + formatValue, + marks, +}: SettingsSliderProps) { + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+ {(label || formatValue) && ( +
+ {label && } + {formatValue && ( + + {formatValue(value)} + + )} +
+ )} +
+ onChange(Number(e.target.value))} + disabled={disabled} + className="w-full h-1.5 bg-transparent appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed relative z-10" + style={{ + background: `linear-gradient(to right, hsl(var(--primary)) ${percentage}%, hsl(var(--muted)) ${percentage}%)`, + borderRadius: "9999px", + }} + /> + {marks && ( +
+ {marks.map((mark) => ( + {mark.label} + ))} +
+ )} +
+ {description &&

{description}

} +
+ ); +} + +// Status badge component +interface SettingsStatusBadgeProps { + status: "healthy" | "warning" | "error" | "info"; + label: string; +} + +export function SettingsStatusBadge({ status, label }: SettingsStatusBadgeProps) { + const styles = { + healthy: "bg-green-500/10 text-green-400 border-green-500/20", + warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", + error: "bg-red-500/10 text-red-400 border-red-500/20", + info: "bg-blue-500/10 text-blue-400 border-blue-500/20", + }; + + return ( + + {label} + + ); +} + +// Divider component +export function SettingsDivider() { + return
; +} + +// Form actions footer +interface SettingsFormActionsProps { + onCancel?: () => void; + onSave?: () => void; + saveLabel?: string; + cancelLabel?: string; + loading?: boolean; + disabled?: boolean; + dangerAction?: { + label: string; + onClick: () => void; + }; +} + +export function SettingsFormActions({ + onCancel, + onSave, + saveLabel = "Save Changes", + cancelLabel = "Cancel", + loading, + disabled, + dangerAction, +}: SettingsFormActionsProps) { + return ( +
+ {dangerAction ? ( + + ) : ( +
+ )} +
+ {onCancel && ( + + )} + {onSave && ( + + )} +
+
+ ); +} diff --git a/src/app/[locale]/settings/_lib/nav-items.ts b/src/app/[locale]/settings/_lib/nav-items.ts index 334474c32..c73983b24 100644 --- a/src/app/[locale]/settings/_lib/nav-items.ts +++ b/src/app/[locale]/settings/_lib/nav-items.ts @@ -1,61 +1,96 @@ import { getTranslations } from "next-intl/server"; +export type SettingsNavIconName = + | "settings" + | "dollar-sign" + | "server" + | "shield-alert" + | "alert-triangle" + | "filter" + | "smartphone" + | "database" + | "file-text" + | "bell" + | "book-open" + | "help-circle" + | "message-circle" + | "external-link"; + export interface SettingsNavItem { href: string; label: string; - labelKey?: string; // Add key for client-side translation fallback - external?: boolean; // Mark if this is an external link (bypasses i18n routing) + labelKey?: string; + iconName?: SettingsNavIconName; + external?: boolean; } // Static navigation items for navigation structure export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [ - { href: "/settings/config", labelKey: "nav.config", label: "配置" }, - { href: "/settings/prices", labelKey: "nav.prices", label: "价格表" }, - { href: "/settings/providers", labelKey: "nav.providers", label: "供应商" }, + { + href: "/settings/config", + labelKey: "nav.config", + label: "Configuration", + iconName: "settings", + }, + { href: "/settings/prices", labelKey: "nav.prices", label: "Prices", iconName: "dollar-sign" }, + { + href: "/settings/providers", + labelKey: "nav.providers", + label: "Providers", + iconName: "server", + }, { href: "/settings/sensitive-words", labelKey: "nav.sensitiveWords", - label: "敏感词", + label: "Sensitive Words", + iconName: "shield-alert", }, { href: "/settings/error-rules", labelKey: "nav.errorRules", - label: "错误规则", + label: "Error Rules", + iconName: "alert-triangle", }, { href: "/settings/request-filters", labelKey: "nav.requestFilters", - label: "请求过滤", + label: "Request Filters", + iconName: "filter", }, { href: "/settings/client-versions", labelKey: "nav.clientVersions", - label: "客户端升级提醒", + label: "Client Versions", + iconName: "smartphone", }, - { href: "/settings/data", labelKey: "nav.data", label: "数据管理" }, - { href: "/settings/logs", labelKey: "nav.logs", label: "日志" }, + { href: "/settings/data", labelKey: "nav.data", label: "Data", iconName: "database" }, + { href: "/settings/logs", labelKey: "nav.logs", label: "Logs", iconName: "file-text" }, { href: "/settings/notifications", labelKey: "nav.notifications", - label: "消息推送", + label: "Notifications", + iconName: "bell", }, { href: "/api/actions/scalar", labelKey: "nav.apiDocs", - label: "API 文档", + label: "API Docs", external: true, + iconName: "book-open", }, { href: "https://claude-code-hub.app/", labelKey: "nav.docs", - label: "使用文档", + label: "Documentation", external: true, + iconName: "help-circle", }, { href: "https://github.com/ding113/claude-code-hub/issues", labelKey: "nav.feedback", - label: "反馈问题", + label: "Feedback", external: true, + iconName: "message-circle", }, ]; diff --git a/src/app/[locale]/settings/client-versions/_components/client-version-stats-table.tsx b/src/app/[locale]/settings/client-versions/_components/client-version-stats-table.tsx index 10f5db6e0..ed9587a65 100644 --- a/src/app/[locale]/settings/client-versions/_components/client-version-stats-table.tsx +++ b/src/app/[locale]/settings/client-versions/_components/client-version-stats-table.tsx @@ -1,6 +1,15 @@ "use client"; -import { AlertTriangle, Check, Code2, HelpCircle, Package, Terminal } from "lucide-react"; +import { + AlertTriangle, + Check, + Code2, + HelpCircle, + Package, + Tag, + Terminal, + Users, +} from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import { Badge } from "@/components/ui/badge"; import { @@ -20,7 +29,7 @@ interface ClientVersionStatsTableProps { } /** - * 获取客户端类型对应的图标组件 + * Get icon component for client type */ function getClientTypeIcon(clientType: string): React.ComponentType<{ className?: string }> { const icons: Record> = { @@ -37,80 +46,146 @@ export function ClientVersionStatsTable({ data }: ClientVersionStatsTableProps) const t = useTranslations("settings.clientVersions.table"); const tCommon = useTranslations("settings.common"); + // Calculate totals + const totalClients = data.length; + const totalUsers = data.reduce((sum, client) => sum + client.totalUsers, 0); + const clientsWithGA = data.filter((c) => c.gaVersion).length; + return ( -
+
+ {/* Stats Cards */} +
+
+
+ + {t("stats.clientTypes")} +
+

{totalClients}

+
+
+
+ + {t("stats.totalUsers")} +
+

{totalUsers}

+
+
+
+ + {t("stats.withGA")} +
+

{clientsWithGA}

+
+
+
+ + {t("stats.coverage")} +
+

+ {totalClients > 0 ? Math.round((clientsWithGA / totalClients) * 100) : 0}% +

+
+
+ + {/* Client Type Tables */} {data.map((clientStats) => { const displayName = getClientTypeDisplayName(clientStats.clientType); const IconComponent = getClientTypeIcon(clientStats.clientType); return (
- {/* 客户端类型标题 */} -
-
-

- - {displayName} -

-

- {t("internalType")} - {clientStats.clientType} - {" · "} - {t("currentGA")} - - {clientStats.gaVersion || tCommon("none")} - -

+ {/* Client Type Header */} +
+
+
+ +
+
+

{displayName}

+

+ {t("internalType")} + + {clientStats.clientType} + + | + {t("currentGA")} + + {clientStats.gaVersion || tCommon("none")} + +

+
- + {t("usersCount", { count: clientStats.totalUsers })}
- {/* 用户版本列表 */} -
+ {/* Users Table */} +
- - {t("user")} - {t("version")} - {t("lastActive")} - {t("status")} + + + {t("user")} + + + {t("version")} + + + {t("lastActive")} + + + {t("status")} + {clientStats.users.length === 0 ? ( - - + + {t("noUsers")} ) : ( clientStats.users.map((user) => ( - - {user.username} + + {user.username} - {user.version} + + {user.version} + - + {formatDateDistance(new Date(user.lastSeen), new Date(), locale)} {user.isLatest ? ( {t("latest")} ) : user.needsUpgrade ? ( - + {t("needsUpgrade")} ) : ( - + {t("unknown")} diff --git a/src/app/[locale]/settings/client-versions/_components/client-version-toggle.tsx b/src/app/[locale]/settings/client-versions/_components/client-version-toggle.tsx index 5cbb7fda1..04613d3bf 100644 --- a/src/app/[locale]/settings/client-versions/_components/client-version-toggle.tsx +++ b/src/app/[locale]/settings/client-versions/_components/client-version-toggle.tsx @@ -1,13 +1,12 @@ "use client"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, Shield, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState, useTransition } from "react"; import { toast } from "sonner"; import { saveSystemSettings } from "@/actions/system-config"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { SettingsToggleRow } from "../../_components/ui/settings-ui"; interface ClientVersionToggleProps { enabled: boolean; @@ -35,50 +34,61 @@ export function ClientVersionToggle({ enabled }: ClientVersionToggleProps) { return (
- {/* 开关 */} -
-
- -

{t("toggle.description")}

-
- -
+ {/* Toggle Row */} + - {/* 详细说明 */} - - - {t("features.title")} - -
- {t("features.whatHappens")} + {/* Feature Alert */} +
+
+
+
-
    -
  • {t("features.autoDetect")}
  • -
  • - {t("features.gaRule")} - {t("features.gaRuleDesc")} -
  • -
  • - {t("features.activeWindow")} - {t("features.activeWindowDesc")} -
  • -
  • - {t("features.blockOldVersion")} -
  • -
  • {t("features.errorMessage")}
  • -
+
+

{t("features.title")}

+
+

{t("features.whatHappens")}

+
    +
  • {t("features.autoDetect")}
  • +
  • + {t("features.gaRule")} + {t("features.gaRuleDesc")} +
  • +
  • + {t("features.activeWindow")} + {t("features.activeWindowDesc")} +
  • +
  • + {t("features.blockOldVersion")} +
  • +
  • {t("features.errorMessage")}
  • +
-
- {t("features.recommendation")} - {t("features.recommendationDesc")} +
+ {t("features.recommendation")} + {t("features.recommendationDesc")} +
+
- - +
+
); } diff --git a/src/app/[locale]/settings/client-versions/_components/client-versions-skeleton.tsx b/src/app/[locale]/settings/client-versions/_components/client-versions-skeleton.tsx index f38149f28..ab516b0a0 100644 --- a/src/app/[locale]/settings/client-versions/_components/client-versions-skeleton.tsx +++ b/src/app/[locale]/settings/client-versions/_components/client-versions-skeleton.tsx @@ -4,15 +4,37 @@ import { Skeleton } from "@/components/ui/skeleton"; export function ClientVersionsSkeleton() { return (
-
- - - + {/* Settings Section Skeleton */} +
+
+ +
+ + +
+
+
+ +
-
- - - + {/* Stats Section Skeleton */} +
+
+ +
+ + +
+
+
+ {[...Array(4)].map((_, i) => ( +
+ + +
+ ))} +
+
); @@ -20,17 +42,62 @@ export function ClientVersionsSkeleton() { export function ClientVersionsSettingsSkeleton() { return ( -
- - +
+
+
+ +
+ + +
+
+ +
+
+
+ +
+ + + +
+
+
); } export function ClientVersionsTableSkeleton() { return ( -
- +
+ {/* Stats Cards Skeleton */} +
+ {[...Array(4)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+ {/* Table Skeleton */} +
+
+
+ +
+ + +
+
+ +
+
+ +
+
); diff --git a/src/app/[locale]/settings/client-versions/page.tsx b/src/app/[locale]/settings/client-versions/page.tsx index c80593567..3677b52c6 100644 --- a/src/app/[locale]/settings/client-versions/page.tsx +++ b/src/app/[locale]/settings/client-versions/page.tsx @@ -2,10 +2,10 @@ import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; import { fetchClientVersionStats } from "@/actions/client-versions"; import { fetchSystemSettings } from "@/actions/system-config"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { redirect } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; import { SettingsPageHeader } from "../_components/settings-page-header"; +import { SettingsSection } from "../_components/ui/settings-ui"; import { ClientVersionStatsTable } from "./_components/client-version-stats-table"; import { ClientVersionToggle } from "./_components/client-version-toggle"; import { @@ -33,32 +33,31 @@ export default async function ClientVersionsPage({ - {/* 功能开关和说明 */} - - - {t("clientVersions.section.settings.title")} - {t("clientVersions.section.settings.description")} - - - }> - - - - + {/* Settings Toggle Section */} + + }> + + + - {/* 版本统计表格 */} - - - {t("clientVersions.section.distribution.title")} - {t("clientVersions.section.distribution.description")} - - - }> - - - - + {/* Version Distribution Section */} + + }> + + +
); } @@ -79,7 +78,7 @@ async function ClientVersionsStatsContent() { if (!stats || stats.length === 0) { return ( -
+

{t("clientVersions.empty.title")}

{t("clientVersions.empty.description")} diff --git a/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx b/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx index 178d32dc4..4f8b581ad 100644 --- a/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx +++ b/src/app/[locale]/settings/config/_components/auto-cleanup-form.tsx @@ -1,7 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Loader2 } from "lucide-react"; +import { Calendar, Clock, Database, Loader2, Power } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -13,9 +13,6 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import type { SystemSettings } from "@/types/system-config"; -/** - * 自动清理配置表单 Schema - */ const autoCleanupSchema = z.object({ enableAutoCleanup: z.boolean(), cleanupRetentionDays: z.number().int().min(1).max(365), @@ -82,13 +79,21 @@ export function AutoCleanupForm({ settings, onSuccess }: AutoCleanupFormProps) { } }; + const inputClassName = + "bg-muted/50 border border-border rounded-lg focus:border-primary focus:ring-1 focus:ring-primary"; + return ( -
- {/* 启用开关 */} -

-
- -

{t("enableAutoCleanupDesc")}

+ + {/* Enable Auto Cleanup Toggle */} +
+
+
+ +
+
+

{t("enableAutoCleanup")}

+

{t("enableAutoCleanupDesc")}

+
- {/* 仅在启用时显示配置项 */} + {/* Conditional Settings */} {enableAutoCleanup && ( - <> - {/* 保留天数 */} +
+ {/* Retention Days */}
- +
+
+ +
+ +
{errors.cleanupRetentionDays && (

{errors.cleanupRetentionDays.message}

@@ -117,14 +130,22 @@ export function AutoCleanupForm({ settings, onSuccess }: AutoCleanupFormProps) {

{t("cleanupRetentionDaysDesc")}

- {/* Cron 表达式 */} + {/* Cron Schedule */}
- +
+
+ +
+ +
{errors.cleanupSchedule && (

{errors.cleanupSchedule.message}

@@ -136,9 +157,16 @@ export function AutoCleanupForm({ settings, onSuccess }: AutoCleanupFormProps) {

- {/* 批量大小 */} + {/* Batch Size */}
- +
+
+ +
+ +
{errors.cleanupBatchSize && (

{errors.cleanupBatchSize.message}

)}

{t("cleanupBatchSizeDesc")}

- +
)} - {/* 提交按钮 */} - + {/* Submit Button */} +
+ +
); } diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index b0c6e1164..5f1becb90 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -1,5 +1,16 @@ "use client"; +import { + AlertTriangle, + Eye, + FileCode, + Network, + Pencil, + Terminal, + Thermometer, + Wrench, + Zap, +} from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState, useTransition } from "react"; @@ -115,15 +126,22 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) } toast.success(t("configUpdated")); - // Refresh Server Components to apply changes (currency display, etc.) router.refresh(); }); }; + const inputClassName = + "bg-muted/50 border border-border rounded-lg focus:border-primary focus:ring-1 focus:ring-primary"; + const selectTriggerClassName = + "bg-muted/50 border border-border rounded-lg focus:border-primary focus:ring-1 focus:ring-primary"; + return ( -
+ + {/* Site Title Input */}
- +

{t("siteTitleDesc")}

+ {/* Currency Display Select */}
- + setBillingModelSource(value as BillingModelSource)} disabled={isPending} > - + @@ -177,181 +202,240 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)

{t("billingModelSourceDesc")}

-
-
- -

{t("allowGlobalViewDesc")}

+ {/* Toggle Settings */} +
+ {/* Allow Global Usage View */} +
+
+
+ +
+
+

{t("allowGlobalView")}

+

{t("allowGlobalViewDesc")}

+
+
+ setAllowGlobalUsageView(checked)} + disabled={isPending} + />
- setAllowGlobalUsageView(checked)} - disabled={isPending} - /> -
-
-
- -

{t("verboseProviderErrorDesc")}

+ {/* Verbose Provider Error */} +
+
+
+ +
+
+

{t("verboseProviderError")}

+

+ {t("verboseProviderErrorDesc")} +

+
+
+ setVerboseProviderError(checked)} + disabled={isPending} + />
- setVerboseProviderError(checked)} - disabled={isPending} - /> -
-
-
- -

{t("enableHttp2Desc")}

+ {/* Enable HTTP/2 */} +
+
+
+ +
+
+

{t("enableHttp2")}

+

{t("enableHttp2Desc")}

+
+
+ setEnableHttp2(checked)} + disabled={isPending} + />
- setEnableHttp2(checked)} - disabled={isPending} - /> -
-
-
- -

- {t("interceptAnthropicWarmupRequestsDesc")} -

-
- setInterceptAnthropicWarmupRequests(checked)} - disabled={isPending} - /> -
- -
-
- -

- {t("enableThinkingSignatureRectifierDesc")} -

+ {/* Intercept Anthropic Warmup Requests */} +
+
+
+ +
+
+

+ {t("interceptAnthropicWarmupRequests")} +

+

+ {t("interceptAnthropicWarmupRequestsDesc")} +

+
+
+ setInterceptAnthropicWarmupRequests(checked)} + disabled={isPending} + />
- setEnableThinkingSignatureRectifier(checked)} - disabled={isPending} - /> -
-
-
- -

- {t("enableCodexSessionIdCompletionDesc")} -

+ {/* Enable Thinking Signature Rectifier */} +
+
+
+ +
+
+

+ {t("enableThinkingSignatureRectifier")} +

+

+ {t("enableThinkingSignatureRectifierDesc")} +

+
+
+ setEnableThinkingSignatureRectifier(checked)} + disabled={isPending} + />
- setEnableCodexSessionIdCompletion(checked)} - disabled={isPending} - /> -
-
-
-
- -

{t("enableResponseFixerDesc")}

+ {/* Enable Codex Session ID Completion */} +
+
+
+ +
+
+

+ {t("enableCodexSessionIdCompletion")} +

+

+ {t("enableCodexSessionIdCompletionDesc")} +

+
setEnableResponseFixer(checked)} + id="enable-codex-session-id-completion" + checked={enableCodexSessionIdCompletion} + onCheckedChange={(checked) => setEnableCodexSessionIdCompletion(checked)} disabled={isPending} />
- {enableResponseFixer && ( -
-
+ {/* Response Fixer Section */} +
+
+
+
+ +
- -

- {t("responseFixerFixEncodingDesc")} +

{t("enableResponseFixer")}

+

+ {t("enableResponseFixerDesc")}

- - setResponseFixerConfig((prev) => ({ ...prev, fixEncoding: checked })) - } - disabled={isPending} - />
+ setEnableResponseFixer(checked)} + disabled={isPending} + /> +
-
-
- -

- {t("responseFixerFixSseFormatDesc")} -

+ {enableResponseFixer && ( +
+ {/* Fix Encoding */} +
+
+
+ +
+
+

+ {t("responseFixerFixEncoding")} +

+

+ {t("responseFixerFixEncodingDesc")} +

+
+
+ + setResponseFixerConfig((prev) => ({ ...prev, fixEncoding: checked })) + } + disabled={isPending} + />
- - setResponseFixerConfig((prev) => ({ ...prev, fixSseFormat: checked })) - } - disabled={isPending} - /> -
-
-
- -

- {t("responseFixerFixTruncatedJsonDesc")} -

+ {/* Fix SSE Format */} +
+
+
+ +
+
+

+ {t("responseFixerFixSseFormat")} +

+

+ {t("responseFixerFixSseFormatDesc")} +

+
+
+ + setResponseFixerConfig((prev) => ({ ...prev, fixSseFormat: checked })) + } + disabled={isPending} + /> +
+ + {/* Fix Truncated JSON */} +
+
+
+ +
+
+

+ {t("responseFixerFixTruncatedJson")} +

+

+ {t("responseFixerFixTruncatedJsonDesc")} +

+
+
+ + setResponseFixerConfig((prev) => ({ ...prev, fixTruncatedJson: checked })) + } + disabled={isPending} + />
- - setResponseFixerConfig((prev) => ({ ...prev, fixTruncatedJson: checked })) - } - disabled={isPending} - />
-
- )} + )} +
-
+
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index ae0b90dc9..7a215aa33 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -14,7 +14,11 @@ export default async function SettingsConfigPage() { return ( <> - + }> @@ -31,6 +35,8 @@ async function SettingsConfigContent() {
diff --git a/src/app/[locale]/settings/data/_components/database-export.tsx b/src/app/[locale]/settings/data/_components/database-export.tsx index 4e53850b1..dc0997e7f 100644 --- a/src/app/[locale]/settings/data/_components/database-export.tsx +++ b/src/app/[locale]/settings/data/_components/database-export.tsx @@ -54,7 +54,11 @@ export function DatabaseExport() {

{t("descriptionFull")}

- diff --git a/src/app/[locale]/settings/data/_components/database-import.tsx b/src/app/[locale]/settings/data/_components/database-import.tsx index 719b18efc..3a8ce8fe7 100644 --- a/src/app/[locale]/settings/data/_components/database-import.tsx +++ b/src/app/[locale]/settings/data/_components/database-import.tsx @@ -182,7 +182,7 @@ export function DatabaseImport() { accept=".dump" onChange={handleFileChange} disabled={isImporting} - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + className="flex h-10 w-full rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#E25706]/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" />
{selectedFile && ( @@ -196,7 +196,7 @@ export function DatabaseImport() {
{/* Import options */} -
+
{isImporting ? t("importing") : t("button")} @@ -226,11 +226,11 @@ export function DatabaseImport() { {/* Progress display */} {progressMessages.length > 0 && ( -
+

{t("progressTitle")}

{progressMessages.map((message, index) => (
diff --git a/src/app/[locale]/settings/data/_components/database-status.tsx b/src/app/[locale]/settings/data/_components/database-status.tsx index c080c7469..9942a5618 100644 --- a/src/app/[locale]/settings/data/_components/database-status.tsx +++ b/src/app/[locale]/settings/data/_components/database-status.tsx @@ -51,7 +51,7 @@ export function DatabaseStatusDisplay() { if (error) { return ( -
+

{error}

@@ -68,57 +68,48 @@ export function DatabaseStatusDisplay() { } return ( -
- {/* Compact horizontal status bar */} -
- {/* Connection status */} -
+
+ {/* Status header with badge */} +
+
{status.isAvailable ? ( - <> -
- {t("connected")} - + + {t("connected")} + ) : ( - <> -
- {t("unavailable")} - + + {t("unavailable")} + )}
- - {/* Separator */} - {status.isAvailable && ( - <> -
- - {/* Database size */} -
- - {status.databaseSize} -
- - {/* Separator */} -
- - {/* Table count */} -
-
- - {t("tables", { count: status.tableCount })} - - - - )} - - {/* Refresh button */} - - {/* 错误信息 */} + {/* Glass cards for stats */} + {status.isAvailable && ( +
+
+
+ + {t("size")} +
+

{status.databaseSize}

+
+
+
+
+ {t("tableCount")} + +

{status.tableCount}

+ + + )} + + {/* Error message */} {status.error && ( -
+
{status.error}
)} diff --git a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx index 527e1f658..2e6d4d9db 100644 --- a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx +++ b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx @@ -128,18 +128,23 @@ export function LogCleanupPanel() { return (

- {t("descriptionWarning").split("注意:")[0]} + {t("descriptionWarning").split("Note:")[0]} - {t("descriptionWarning").includes("注意:") - ? t("descriptionWarning").split("注意:")[1] - : ""} + {t("descriptionWarning").includes("Note:") + ? t("descriptionWarning").split("Note:")[1] + : t("descriptionWarning").includes(":") + ? t("descriptionWarning").split(":")[1] + : ""}

-
+
+ + setPattern(e.target.value)} placeholder={t("errorRules.dialog.patternPlaceholder")} required + className={cn( + "w-full bg-black/20 border border-white/10 rounded-lg py-2 px-3 text-sm text-foreground font-mono", + "placeholder:text-muted-foreground/50", + "focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all" + )} />

{t("errorRules.dialog.patternHint")}

-
- +
+