)}
- ,
+ ,
document.body,
);
};
diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx
index 20d4e8d7..da31ce92 100644
--- a/src/components/env/EnvWarningBanner.tsx
+++ b/src/components/env/EnvWarningBanner.tsx
@@ -191,11 +191,11 @@ export function EnvWarningBanner({
{conflict.varName}
-
+
{t("env.field.value")}: {conflict.varValue}
diff --git a/src/components/mcp/McpWizardModal.tsx b/src/components/mcp/McpWizardModal.tsx
index fe4336f7..a5cf5de2 100644
--- a/src/components/mcp/McpWizardModal.tsx
+++ b/src/components/mcp/McpWizardModal.tsx
@@ -239,7 +239,7 @@ const McpWizardModal: React.FC = ({
{/* Hint */}
-
+
{t("mcp.wizard.hint")}
@@ -248,7 +248,7 @@ const McpWizardModal: React.FC
= ({
{/* Type */}
-
+
{t("mcp.wizard.type")} *
@@ -262,7 +262,7 @@ const McpWizardModal: React.FC
= ({
}
className="w-4 h-4 accent-blue-500"
/>
-
+
{t("mcp.wizard.typeStdio")}
@@ -276,7 +276,7 @@ const McpWizardModal: React.FC = ({
}
className="w-4 h-4 accent-blue-500"
/>
-
+
{t("mcp.wizard.typeHttp")}
@@ -290,7 +290,7 @@ const McpWizardModal: React.FC = ({
}
className="w-4 h-4 accent-blue-500"
/>
-
+
{t("mcp.wizard.typeSse")}
@@ -299,7 +299,7 @@ const McpWizardModal: React.FC = ({
{/* Title */}
-
+
{t("mcp.form.title")} *
= ({
<>
{/* Command */}
-
+
{t("mcp.wizard.command")}{" "}
*
@@ -333,7 +333,7 @@ const McpWizardModal: React.FC = ({
{/* Args */}
-
+
{t("mcp.wizard.args")}
{/* Env */}
-
+
{t("mcp.wizard.env")}
>
@@ -366,7 +366,7 @@ const McpWizardModal: React.FC = ({
<>
{/* URL */}
-
+
{t("mcp.wizard.url")}{" "}
*
@@ -382,7 +382,7 @@ const McpWizardModal: React.FC = ({
{/* Headers */}
-
+
{t("mcp.wizard.headers")}
>
@@ -404,7 +404,7 @@ const McpWizardModal: React.FC = ({
wizardUrl ||
wizardHeaders) && (
-
+
{t("mcp.wizard.preview")}
diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx
index b04dd944..00cb0d1c 100644
--- a/src/components/providers/AddProviderDialog.tsx
+++ b/src/components/providers/AddProviderDialog.tsx
@@ -1,17 +1,23 @@
-import { useCallback } from "react";
+import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
+import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
-import type { Provider, CustomEndpoint } from "@/types";
+import type { Provider, CustomEndpoint, UniversalProvider } from "@/types";
import type { AppId } from "@/lib/api";
+import { universalProvidersApi } from "@/lib/api";
import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
+import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
+import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
+import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps {
open: boolean;
@@ -27,6 +33,46 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
+ const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
+ "app-specific",
+ );
+ const [universalFormOpen, setUniversalFormOpen] = useState(false);
+ const [selectedUniversalPreset, setSelectedUniversalPreset] =
+ useState(null);
+
+ // Handle universal provider save
+ const handleUniversalProviderSave = useCallback(
+ async (provider: UniversalProvider) => {
+ try {
+ await universalProvidersApi.upsert(provider);
+ toast.success(
+ t("universalProvider.addSuccess", {
+ defaultValue: "统一供应商添加成功",
+ }),
+ );
+ setUniversalFormOpen(false);
+ setSelectedUniversalPreset(null);
+ onOpenChange(false);
+ } catch (error) {
+ console.error(
+ "[AddProviderDialog] Failed to save universal provider",
+ error,
+ );
+ toast.error(
+ t("universalProvider.addFailed", {
+ defaultValue: "统一供应商添加失败",
+ }),
+ );
+ }
+ },
+ [t, onOpenChange],
+ );
+
+ // Close universal form and return to main dialog
+ const handleUniversalFormClose = useCallback(() => {
+ setUniversalFormOpen(false);
+ setSelectedUniversalPreset(null);
+ }, []);
const handleSubmit = useCallback(
async (values: ProviderFormValues) => {
@@ -156,46 +202,86 @@ export function AddProviderDialog({
[appId, onSubmit, onOpenChange],
);
- const submitLabel =
- appId === "claude"
- ? t("provider.addClaudeProvider")
- : appId === "codex"
- ? t("provider.addCodexProvider")
- : t("provider.addGeminiProvider");
-
- const footer = (
- <>
- onOpenChange(false)}
- className="border-border/20 hover:bg-accent hover:text-accent-foreground"
- >
- {t("common.cancel")}
-
-
-
- {t("common.add")}
-
- >
- );
+ // 动态 footer:根据当前 Tab 显示不同按钮
+ const footer =
+ activeTab === "app-specific" ? (
+ <>
+ onOpenChange(false)}
+ className="border-border/20 hover:bg-accent hover:text-accent-foreground"
+ >
+ {t("common.cancel")}
+
+
+
+ {t("common.add")}
+
+ >
+ ) : (
+ <>
+ onOpenChange(false)}
+ className="border-border/20 hover:bg-accent hover:text-accent-foreground"
+ >
+ {t("common.cancel")}
+
+ setUniversalFormOpen(true)}
+ className="bg-primary text-primary-foreground hover:bg-primary/90"
+ >
+
+ {t("universalProvider.add")}
+
+ >
+ );
return (
onOpenChange(false)}
footer={footer}
>
- onOpenChange(false)}
- showButtons={false}
+ setActiveTab(v as "app-specific" | "universal")}
+ >
+
+
+ {t(`apps.${appId}`)} {t("provider.tabProvider")}
+
+
+ {t("provider.tabUniversal")}
+
+
+
+
+ onOpenChange(false)}
+ showButtons={false}
+ />
+
+
+
+
+
+
+
+ {/* Universal Provider Form Modal */}
+
);
diff --git a/src/components/providers/forms/ApiKeyInput.tsx b/src/components/providers/forms/ApiKeyInput.tsx
index 0c4a1ef7..784124bf 100644
--- a/src/components/providers/forms/ApiKeyInput.tsx
+++ b/src/components/providers/forms/ApiKeyInput.tsx
@@ -31,15 +31,12 @@ const ApiKeyInput: React.FC = ({
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
- : "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
+ : "border-border-default bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
}`;
return (
-
+
{label} {required && "*"}
@@ -58,7 +55,7 @@ const ApiKeyInput: React.FC
= ({
{showKey ? : }
diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx
index d0df92bb..351f11b4 100644
--- a/src/components/providers/forms/ClaudeFormFields.tsx
+++ b/src/components/providers/forms/ClaudeFormFields.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
+import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
@@ -39,12 +40,14 @@ interface ClaudeFormFieldsProps {
// Model Selector
shouldShowModelSelector: boolean;
claudeModel: string;
+ reasoningModel: string;
defaultHaikuModel: string;
defaultSonnetModel: string;
defaultOpusModel: string;
onModelChange: (
field:
| "ANTHROPIC_MODEL"
+ | "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
@@ -53,6 +56,11 @@ interface ClaudeFormFieldsProps {
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
+
+ // OpenRouter Compat
+ showOpenRouterCompatToggle: boolean;
+ openRouterCompatEnabled: boolean;
+ onOpenRouterCompatChange: (enabled: boolean) => void;
}
export function ClaudeFormFields({
@@ -77,11 +85,15 @@ export function ClaudeFormFields({
onCustomEndpointsChange,
shouldShowModelSelector,
claudeModel,
+ reasoningModel,
defaultHaikuModel,
defaultSonnetModel,
defaultOpusModel,
onModelChange,
speedTestEndpoints,
+ showOpenRouterCompatToggle,
+ openRouterCompatEnabled,
+ onOpenRouterCompatChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -162,6 +174,28 @@ export function ClaudeFormFields({
/>
)}
+ {showOpenRouterCompatToggle && (
+
+
+
+ {t("providerForm.openrouterCompatMode", {
+ defaultValue: "OpenRouter 兼容模式",
+ })}
+
+
+ {t("providerForm.openrouterCompatModeHint", {
+ defaultValue:
+ "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
+ })}
+
+
+
+
+ )}
+
{/* 模型选择器 */}
{shouldShowModelSelector && (
@@ -185,6 +219,27 @@ export function ClaudeFormFields({
/>
+ {/* 推理模型 */}
+
+
+ {t("providerForm.anthropicReasoningModel", {
+ defaultValue: "推理模型 (Thinking)",
+ })}
+
+
+ onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
+ }
+ placeholder={t("providerForm.reasoningModelPlaceholder", {
+ defaultValue: "",
+ })}
+ autoComplete="off"
+ />
+
+
{/* 默认 Haiku */}
diff --git a/src/components/providers/forms/CodexConfigSections.tsx b/src/components/providers/forms/CodexConfigSections.tsx
index 6eaa9c3a..99ada427 100644
--- a/src/components/providers/forms/CodexConfigSections.tsx
+++ b/src/components/providers/forms/CodexConfigSections.tsx
@@ -47,7 +47,7 @@ export const CodexAuthSection: React.FC = ({
{t("codexConfig.authJson")}
@@ -67,7 +67,7 @@ export const CodexAuthSection: React.FC
= ({
)}
{!error && (
-
+
{t("codexConfig.authJsonHint")}
)}
@@ -120,12 +120,12 @@ export const CodexConfigSection: React.FC = ({
{t("codexConfig.configToml")}
-
+
= ({
)}
{!configError && (
-
+
{t("codexConfig.configTomlHint")}
)}
diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx
index fefe41c3..f8cada48 100644
--- a/src/components/providers/forms/CodexFormFields.tsx
+++ b/src/components/providers/forms/CodexFormFields.tsx
@@ -98,7 +98,7 @@ export function CodexFormFields({
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
@@ -110,9 +110,9 @@ export function CodexFormFields({
placeholder={t("codexConfig.modelNamePlaceholder", {
defaultValue: "例如: gpt-5-codex",
})}
- className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
+ className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
/>
-
+
{t("codexConfig.modelNameHint", {
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
})}
diff --git a/src/components/providers/forms/GeminiConfigSections.tsx b/src/components/providers/forms/GeminiConfigSections.tsx
index 9eb94834..09132e28 100644
--- a/src/components/providers/forms/GeminiConfigSections.tsx
+++ b/src/components/providers/forms/GeminiConfigSections.tsx
@@ -47,7 +47,7 @@ export const GeminiEnvSection: React.FC = ({
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
@@ -69,7 +69,7 @@ GEMINI_MODEL=gemini-3-pro-preview`}
)}
{!error && (
-
+
{t("geminiConfig.envFileHint", {
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
})}
@@ -124,14 +124,14 @@ export const GeminiConfigSection: React.FC = ({
{t("geminiConfig.configJson", {
defaultValue: "配置文件 (config.json)",
})}
-
+
= ({
)}
{!configError && (
-
+
{t("geminiConfig.configJsonHint", {
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
})}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx
index 81b530e2..cf58b239 100644
--- a/src/components/providers/forms/ProviderForm.tsx
+++ b/src/components/providers/forms/ProviderForm.tsx
@@ -20,6 +20,7 @@ import {
geminiProviderPresets,
type GeminiProviderPreset,
} from "@/config/geminiProviderPresets";
+import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
@@ -72,6 +73,8 @@ interface ProviderFormProps {
submitLabel: string;
onSubmit: (values: ProviderFormValues) => void;
onCancel: () => void;
+ onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
+ onManageUniversalProviders?: () => void;
initialData?: {
name?: string;
websiteUrl?: string;
@@ -91,6 +94,8 @@ export function ProviderForm({
submitLabel,
onSubmit,
onCancel,
+ onUniversalPresetSelect,
+ onManageUniversalProviders,
initialData,
showButtons = true,
}: ProviderFormProps) {
@@ -162,6 +167,8 @@ export function ProviderForm({
mode: "onSubmit",
});
+ const settingsConfigValue = form.watch("settingsConfig");
+
// 使用 API Key hook
const {
apiKey,
@@ -187,9 +194,10 @@ export function ProviderForm({
},
});
- // 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型)
+ // 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型)
const {
claudeModel,
+ reasoningModel,
defaultHaikuModel,
defaultSonnetModel,
defaultOpusModel,
@@ -199,6 +207,53 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
+ const isOpenRouterProvider = useMemo(() => {
+ if (appId !== "claude") return false;
+ const normalized = baseUrl.trim().toLowerCase();
+ if (normalized.includes("openrouter.ai")) {
+ return true;
+ }
+ try {
+ const config = JSON.parse(settingsConfigValue || "{}");
+ const envUrl = config?.env?.ANTHROPIC_BASE_URL;
+ return typeof envUrl === "string" && envUrl.includes("openrouter.ai");
+ } catch {
+ return false;
+ }
+ }, [appId, baseUrl, settingsConfigValue]);
+
+ const openRouterCompatEnabled = useMemo(() => {
+ if (!isOpenRouterProvider) return false;
+ try {
+ const config = JSON.parse(settingsConfigValue || "{}");
+ const raw = config?.openrouter_compat_mode;
+ if (typeof raw === "boolean") return raw;
+ if (typeof raw === "number") return raw !== 0;
+ if (typeof raw === "string") {
+ const normalized = raw.trim().toLowerCase();
+ return normalized === "true" || normalized === "1";
+ }
+ } catch {
+ // ignore
+ }
+ return true;
+ }, [isOpenRouterProvider, settingsConfigValue]);
+
+ const handleOpenRouterCompatChange = useCallback(
+ (enabled: boolean) => {
+ try {
+ const currentConfig = JSON.parse(
+ form.getValues("settingsConfig") || "{}",
+ );
+ currentConfig.openrouter_compat_mode = enabled;
+ form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2));
+ } catch {
+ // ignore
+ }
+ },
+ [form],
+ );
+
// 使用 Codex 配置 hook (仅 Codex 模式)
const {
codexAuth,
@@ -753,6 +808,8 @@ export function ProviderForm({
categoryKeys={categoryKeys}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
+ onUniversalPresetSelect={onUniversalPresetSelect}
+ onManageUniversalProviders={onManageUniversalProviders}
category={category}
/>
)}
@@ -789,11 +846,15 @@ export function ProviderForm({
}
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
+ reasoningModel={reasoningModel}
defaultHaikuModel={defaultHaikuModel}
defaultSonnetModel={defaultSonnetModel}
defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
+ showOpenRouterCompatToggle={isOpenRouterProvider}
+ openRouterCompatEnabled={openRouterCompatEnabled}
+ onOpenRouterCompatChange={handleOpenRouterCompatChange}
/>
)}
diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx
index 80d8220f..611b7c7c 100644
--- a/src/components/providers/forms/ProviderPresetSelector.tsx
+++ b/src/components/providers/forms/ProviderPresetSelector.tsx
@@ -1,11 +1,16 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
-import { Zap, Star } from "lucide-react";
+import { Zap, Star, Layers, Settings2 } from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
import type { ProviderCategory } from "@/types";
+import {
+ universalProviderPresets,
+ type UniversalProviderPreset,
+} from "@/config/universalProviderPresets";
+import { ProviderIcon } from "@/components/ProviderIcon";
type PresetEntry = {
id: string;
@@ -18,6 +23,8 @@ interface ProviderPresetSelectorProps {
categoryKeys: string[];
presetCategoryLabels: Record;
onPresetChange: (value: string) => void;
+ onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
+ onManageUniversalProviders?: () => void;
category?: ProviderCategory; // 当前选中的分类
}
@@ -27,6 +34,8 @@ export function ProviderPresetSelector({
categoryKeys,
presetCategoryLabels,
onPresetChange,
+ onUniversalPresetSelect,
+ onManageUniversalProviders,
category,
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();
@@ -164,6 +173,49 @@ export function ProviderPresetSelector({
});
})}
+
+ {/* 统一供应商预设(新的一行) */}
+ {onUniversalPresetSelect && universalProviderPresets.length > 0 && (
+ <>
+
+ {universalProviderPresets.map((preset) => (
+
onUniversalPresetSelect(preset)}
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative"
+ title={t("universalProvider.hint", {
+ defaultValue:
+ "跨应用统一配置,自动同步到 Claude/Codex/Gemini",
+ })}
+ >
+
+ {preset.name}
+
+
+
+
+ ))}
+ {/* 管理统一供应商按钮 */}
+ {onManageUniversalProviders && (
+
+
+ {t("universalProvider.manage", {
+ defaultValue: "管理",
+ })}
+
+ )}
+
+ >
+ )}
+
{getCategoryHint()}
);
diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts
index 6e2fe4b6..07d7122d 100644
--- a/src/components/providers/forms/hooks/useModelState.ts
+++ b/src/components/providers/forms/hooks/useModelState.ts
@@ -7,13 +7,14 @@ interface UseModelStateProps {
/**
* 管理模型选择状态
- * 支持 ANTHROPIC_MODEL 和 ANTHROPIC_SMALL_FAST_MODEL
+ * 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
*/
export function useModelState({
settingsConfig,
onConfigChange,
}: UseModelStateProps) {
const [claudeModel, setClaudeModel] = useState("");
+ const [reasoningModel, setReasoningModel] = useState("");
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
const [defaultOpusModel, setDefaultOpusModel] = useState("");
@@ -29,6 +30,10 @@ export function useModelState({
const env = cfg?.env || {};
const model =
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
+ const reasoning =
+ typeof env.ANTHROPIC_REASONING_MODEL === "string"
+ ? env.ANTHROPIC_REASONING_MODEL
+ : "";
const small =
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
? env.ANTHROPIC_SMALL_FAST_MODEL
@@ -47,6 +52,7 @@ export function useModelState({
: model || small;
setClaudeModel(model || "");
+ setReasoningModel(reasoning || "");
setDefaultHaikuModel(haiku || "");
setDefaultSonnetModel(sonnet || "");
setDefaultOpusModel(opus || "");
@@ -59,12 +65,14 @@ export function useModelState({
(
field:
| "ANTHROPIC_MODEL"
+ | "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
value: string,
) => {
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
+ if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
setDefaultHaikuModel(value);
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
@@ -98,6 +106,8 @@ export function useModelState({
return {
claudeModel,
setClaudeModel,
+ reasoningModel,
+ setReasoningModel,
defaultHaikuModel,
setDefaultHaikuModel,
defaultSonnetModel,
diff --git a/src/components/providers/forms/shared/EndpointField.tsx b/src/components/providers/forms/shared/EndpointField.tsx
index 19dbcab1..e8e8b977 100644
--- a/src/components/providers/forms/shared/EndpointField.tsx
+++ b/src/components/providers/forms/shared/EndpointField.tsx
@@ -40,7 +40,7 @@ export function EndpointField({
{manageButtonLabel || defaultManageLabel}
diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx
index 182dbce5..9daeaed0 100644
--- a/src/components/proxy/AutoFailoverConfigPanel.tsx
+++ b/src/components/proxy/AutoFailoverConfigPanel.tsx
@@ -6,51 +6,67 @@ import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
-import {
- useCircuitBreakerConfig,
- useUpdateCircuitBreakerConfig,
-} from "@/lib/query/failover";
+import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy";
export interface AutoFailoverConfigPanelProps {
- enabled?: boolean;
- onEnabledChange?: (enabled: boolean) => void;
+ appType: string;
+ disabled?: boolean;
}
export function AutoFailoverConfigPanel({
- enabled = true,
- onEnabledChange: _onEnabledChange,
-}: AutoFailoverConfigPanelProps = {}) {
- // Note: onEnabledChange is currently unused but kept in the interface
- // for potential future use by parent components
- void _onEnabledChange;
+ appType,
+ disabled = false,
+}: AutoFailoverConfigPanelProps) {
const { t } = useTranslation();
- const { data: config, isLoading, error } = useCircuitBreakerConfig();
- const updateConfig = useUpdateCircuitBreakerConfig();
+ const { data: config, isLoading, error } = useAppProxyConfig(appType);
+ const updateConfig = useUpdateAppProxyConfig();
const [formData, setFormData] = useState({
- failureThreshold: 5,
- successThreshold: 2,
- timeoutSeconds: 60,
- errorRateThreshold: 0.5,
- minRequests: 10,
+ autoFailoverEnabled: false,
+ maxRetries: 3,
+ streamingFirstByteTimeout: 30,
+ streamingIdleTimeout: 60,
+ nonStreamingTimeout: 300,
+ circuitFailureThreshold: 5,
+ circuitSuccessThreshold: 2,
+ circuitTimeoutSeconds: 60,
+ circuitErrorRateThreshold: 0.5,
+ circuitMinRequests: 10,
});
useEffect(() => {
if (config) {
setFormData({
- ...config,
+ autoFailoverEnabled: config.autoFailoverEnabled,
+ maxRetries: config.maxRetries,
+ streamingFirstByteTimeout: config.streamingFirstByteTimeout,
+ streamingIdleTimeout: config.streamingIdleTimeout,
+ nonStreamingTimeout: config.nonStreamingTimeout,
+ circuitFailureThreshold: config.circuitFailureThreshold,
+ circuitSuccessThreshold: config.circuitSuccessThreshold,
+ circuitTimeoutSeconds: config.circuitTimeoutSeconds,
+ circuitErrorRateThreshold: config.circuitErrorRateThreshold,
+ circuitMinRequests: config.circuitMinRequests,
});
}
}, [config]);
const handleSave = async () => {
+ if (!config) return;
try {
await updateConfig.mutateAsync({
- failureThreshold: formData.failureThreshold,
- successThreshold: formData.successThreshold,
- timeoutSeconds: formData.timeoutSeconds,
- errorRateThreshold: formData.errorRateThreshold,
- minRequests: formData.minRequests,
+ appType,
+ enabled: config.enabled,
+ autoFailoverEnabled: formData.autoFailoverEnabled,
+ maxRetries: formData.maxRetries,
+ streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
+ streamingIdleTimeout: formData.streamingIdleTimeout,
+ nonStreamingTimeout: formData.nonStreamingTimeout,
+ circuitFailureThreshold: formData.circuitFailureThreshold,
+ circuitSuccessThreshold: formData.circuitSuccessThreshold,
+ circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
+ circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
+ circuitMinRequests: formData.circuitMinRequests,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({
const handleReset = () => {
if (config) {
setFormData({
- ...config,
+ autoFailoverEnabled: config.autoFailoverEnabled,
+ maxRetries: config.maxRetries,
+ streamingFirstByteTimeout: config.streamingFirstByteTimeout,
+ streamingIdleTimeout: config.streamingIdleTimeout,
+ nonStreamingTimeout: config.nonStreamingTimeout,
+ circuitFailureThreshold: config.circuitFailureThreshold,
+ circuitSuccessThreshold: config.circuitSuccessThreshold,
+ circuitTimeoutSeconds: config.circuitTimeoutSeconds,
+ circuitErrorRateThreshold: config.circuitErrorRateThreshold,
+ circuitMinRequests: config.circuitMinRequests,
});
}
};
@@ -79,16 +104,10 @@ export function AutoFailoverConfigPanel({
);
}
+ const isDisabled = disabled || updateConfig.isPending;
+
return (
- {/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
- Since we need it in the accordion header, and this component is inside the content, we can use a portal or
- absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
- and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
- Better yet, let's just render the content directly without the wrapping Card header/collapse logic
- since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
- */}
-
{error && (
@@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
+
+
+
+ {t("proxy.autoFailover.failureThreshold", "失败阈值")}
+
+
+ setFormData({
+ ...formData,
+ circuitFailureThreshold: parseInt(e.target.value) || 5,
+ })
+ }
+ disabled={isDisabled}
/>
{t(
@@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({
)}
+
+
+ {/* 超时配置 */}
+
+
+ {t("proxy.autoFailover.timeoutSettings", "超时配置")}
+
+
+
-
- {t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
+
+ {t(
+ "proxy.autoFailover.streamingFirstByte",
+ "流式首字节超时(秒)",
+ )}
setFormData({
...formData,
- timeoutSeconds: parseInt(e.target.value) || 60,
+ streamingFirstByteTimeout: parseInt(e.target.value) || 30,
})
}
- disabled={!enabled}
+ disabled={isDisabled}
/>
{t(
- "proxy.autoFailover.timeoutHint",
- "熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
+ "proxy.autoFailover.streamingFirstByteHint",
+ "等待首个数据块的最大时间",
+ )}
+
+
+
+
+
+ {t("proxy.autoFailover.streamingIdle", "流式静默超时(秒)")}
+
+
+ setFormData({
+ ...formData,
+ streamingIdleTimeout: parseInt(e.target.value) || 60,
+ })
+ }
+ disabled={isDisabled}
+ />
+
+ {t(
+ "proxy.autoFailover.streamingIdleHint",
+ "数据块之间的最大间隔",
+ )}
+
+
+
+
+
+ {t("proxy.autoFailover.nonStreaming", "非流式超时(秒)")}
+
+
+ setFormData({
+ ...formData,
+ nonStreamingTimeout: parseInt(e.target.value) || 300,
+ })
+ }
+ disabled={isDisabled}
+ />
+
+ {t(
+ "proxy.autoFailover.nonStreamingHint",
+ "非流式请求的总超时时间",
)}
- {/* 熔断器高级配置 */}
+ {/* 熔断器配置 */}
- {t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
+ {t("proxy.autoFailover.circuitBreakerSettings", "熔断器配置")}
-
+
+
+
+
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
setFormData({
...formData,
- errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
+ circuitErrorRateThreshold:
+ (parseInt(e.target.value) || 50) / 100,
})
}
- disabled={!enabled}
+ disabled={isDisabled}
/>
{t(
@@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({
-
+
{t("proxy.autoFailover.minRequests", "最小请求数")}
setFormData({
...formData,
- minRequests: parseInt(e.target.value) || 10,
+ circuitMinRequests: parseInt(e.target.value) || 10,
})
}
- disabled={!enabled}
+ disabled={isDisabled}
/>
{t(
@@ -257,17 +393,10 @@ export function AutoFailoverConfigPanel({
{/* 操作按钮 */}
-
+
{t("common.reset", "重置")}
-
+
{updateConfig.isPending ? (
<>
@@ -281,59 +410,6 @@ export function AutoFailoverConfigPanel({
)}
-
- {/* 说明信息 */}
-
-
- {t("proxy.autoFailover.explanationTitle", "工作原理")}
-
-
-
- •{" "}
-
- {t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
-
- :
- {t(
- "proxy.autoFailover.failureThresholdExplain",
- "连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
- )}
-
-
- •{" "}
-
- {t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
-
- :
- {t(
- "proxy.autoFailover.timeoutExplain",
- "熔断器打开后,等待此时间后尝试半开状态",
- )}
-
-
- •{" "}
-
- {t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
-
- :
- {t(
- "proxy.autoFailover.successThresholdExplain",
- "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
- )}
-
-
- •{" "}
-
- {t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
-
- :
- {t(
- "proxy.autoFailover.errorRateExplain",
- "错误率超过此值时,即使未达到失败阈值也会打开熔断器",
- )}
-
-
-
);
diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx
index 66a1c829..537b8ff6 100644
--- a/src/components/proxy/ProxyPanel.tsx
+++ b/src/components/proxy/ProxyPanel.tsx
@@ -1,26 +1,54 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import {
Activity,
Clock,
TrendingUp,
Server,
ListOrdered,
- Settings,
+ Save,
+ Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
import { useProxyStatus } from "@/hooks/useProxyStatus";
-import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner";
import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
+import {
+ useProxyTakeoverStatus,
+ useSetProxyTakeoverForApp,
+ useGlobalProxyConfig,
+ useUpdateGlobalProxyConfig,
+} from "@/lib/query/proxy";
import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
export function ProxyPanel() {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus();
- const [showSettings, setShowSettings] = useState(false);
+
+ // 获取应用接管状态
+ const { data: takeoverStatus } = useProxyTakeoverStatus();
+ const setTakeoverForApp = useSetProxyTakeoverForApp();
+
+ // 获取全局代理配置
+ const { data: globalConfig } = useGlobalProxyConfig();
+ const updateGlobalConfig = useUpdateGlobalProxyConfig();
+
+ // 监听地址/端口的本地状态
+ const [listenAddress, setListenAddress] = useState("127.0.0.1");
+ const [listenPort, setListenPort] = useState(5000);
+
+ // 同步全局配置到本地状态
+ useEffect(() => {
+ if (globalConfig) {
+ setListenAddress(globalConfig.listenAddress);
+ setListenPort(globalConfig.listenPort);
+ }
+ }, [globalConfig]);
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
// 当前供应商始终优先,队列仅用于失败后的备用顺序
@@ -28,6 +56,69 @@ export function ProxyPanel() {
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
+ const handleTakeoverChange = async (appType: string, enabled: boolean) => {
+ try {
+ await setTakeoverForApp.mutateAsync({ appType, enabled });
+ toast.success(
+ enabled
+ ? t("proxy.takeover.enabled", {
+ app: appType,
+ defaultValue: `${appType} 接管已启用`,
+ })
+ : t("proxy.takeover.disabled", {
+ app: appType,
+ defaultValue: `${appType} 接管已关闭`,
+ }),
+ { closeButton: true },
+ );
+ } catch (error) {
+ toast.error(
+ t("proxy.takeover.failed", {
+ defaultValue: "切换接管状态失败",
+ }),
+ );
+ }
+ };
+
+ const handleLoggingChange = async (enabled: boolean) => {
+ if (!globalConfig) return;
+ try {
+ await updateGlobalConfig.mutateAsync({
+ ...globalConfig,
+ enableLogging: enabled,
+ });
+ toast.success(
+ enabled
+ ? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" })
+ : t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }),
+ { closeButton: true },
+ );
+ } catch (error) {
+ toast.error(
+ t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }),
+ );
+ }
+ };
+
+ const handleSaveBasicConfig = async () => {
+ if (!globalConfig) return;
+ try {
+ await updateGlobalConfig.mutateAsync({
+ ...globalConfig,
+ listenAddress,
+ listenPort,
+ });
+ toast.success(
+ t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
+ { closeButton: true },
+ );
+ } catch (error) {
+ toast.error(
+ t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }),
+ );
+ }
+ };
+
const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -49,22 +140,11 @@ export function ProxyPanel() {
-
-
- {t("proxy.panel.serviceAddress", {
- defaultValue: "服务地址",
- })}
-
-
setShowSettings(true)}
- className="h-7 gap-1.5 text-xs"
- >
-
- {t("common.settings")}
-
-
+
+ {t("proxy.panel.serviceAddress", {
+ defaultValue: "服务地址",
+ })}
+
http://{status.address}:{status.port}
@@ -87,6 +167,11 @@ export function ProxyPanel() {
{t("common.copy")}
+
+ {t("proxy.settings.restartRequired", {
+ defaultValue: "修改监听地址/端口需要先停止代理服务",
+ })}
+
@@ -130,6 +215,63 @@ export function ProxyPanel() {
)}
+ {/* 应用接管开关 */}
+
+
+ {t("proxyConfig.appTakeover", {
+ defaultValue: "应用接管",
+ })}
+
+
+ {(["claude", "codex", "gemini"] as const).map((appType) => {
+ const isEnabled =
+ takeoverStatus?.[
+ appType as keyof typeof takeoverStatus
+ ] ?? false;
+ return (
+
+
+ {appType}
+
+
+ handleTakeoverChange(appType, checked)
+ }
+ disabled={setTakeoverForApp.isPending}
+ />
+
+ );
+ })}
+
+
+
+ {/* 日志记录开关 */}
+
+
+
+
+ {t("proxy.settings.fields.enableLogging.label", {
+ defaultValue: "启用日志记录",
+ })}
+
+
+ {t("proxy.settings.fields.enableLogging.description", {
+ defaultValue: "记录所有代理请求,便于排查问题",
+ })}
+
+
+
+
+
+
{/* 供应商队列 - 按应用类型分组展示 */}
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
@@ -217,36 +359,109 @@ export function ProxyPanel() {
) : (
-
-
-
+
+ {/* 空白区域避免冲突 */}
+
+
+ {/* 基础设置 - 监听地址/端口 */}
+
+
+
+ {t("proxy.settings.basic.title", {
+ defaultValue: "基础设置",
+ })}
+
+
+ {t("proxy.settings.basic.description", {
+ defaultValue: "配置代理服务监听的地址与端口。",
+ })}
+
+
+
+
+
+
+ {t("proxy.settings.fields.listenAddress.label", {
+ defaultValue: "监听地址",
+ })}
+
+
setListenAddress(e.target.value)}
+ placeholder="127.0.0.1"
+ />
+
+ {t("proxy.settings.fields.listenAddress.description", {
+ defaultValue:
+ "代理服务器监听的 IP 地址(推荐 127.0.0.1)",
+ })}
+
+
+
+
+
+ {t("proxy.settings.fields.listenPort.label", {
+ defaultValue: "监听端口",
+ })}
+
+
+ setListenPort(parseInt(e.target.value) || 5000)
+ }
+ placeholder="5000"
+ />
+
+ {t("proxy.settings.fields.listenPort.description", {
+ defaultValue: "代理服务器监听的端口号(1024 ~ 65535)",
+ })}
+
+
+
+
+
+
+ {updateGlobalConfig.isPending ? (
+ <>
+
+ {t("common.saving", { defaultValue: "保存中..." })}
+ >
+ ) : (
+ <>
+
+ {t("common.save", { defaultValue: "保存" })}
+ >
+ )}
+
+
+
+
+ {/* 代理服务已停止提示 */}
+
+
+
+
+
+ {t("proxy.panel.stoppedTitle", {
+ defaultValue: "代理服务已停止",
+ })}
+
+
+ {t("proxy.panel.stoppedDescription", {
+ defaultValue: "使用右上角开关即可启动服务",
+ })}
+
-
- {t("proxy.panel.stoppedTitle", {
- defaultValue: "代理服务已停止",
- })}
-
-
- {t("proxy.panel.stoppedDescription", {
- defaultValue: "使用右上角开关即可启动服务",
- })}
-
-
setShowSettings(true)}
- className="gap-1.5"
- >
-
- {t("proxy.panel.openSettings", {
- defaultValue: "配置代理服务",
- })}
-
)}
-
-
>
);
}
diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx
deleted file mode 100644
index 3ae452f8..00000000
--- a/src/components/proxy/ProxySettingsDialog.tsx
+++ /dev/null
@@ -1,403 +0,0 @@
-/**
- * 代理服务设置对话框
- */
-
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormDescription,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { Switch } from "@/components/ui/switch";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { z } from "zod";
-import { useProxyConfig } from "@/hooks/useProxyConfig";
-import { useEffect, useMemo } from "react";
-import { AlertCircle } from "lucide-react";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { FullScreenPanel } from "@/components/common/FullScreenPanel";
-import { useTranslation } from "react-i18next";
-import type { TFunction } from "i18next";
-import type { ProxyConfig } from "@/types/proxy";
-
-// 表单数据类型(仅包含可编辑字段)
-type ProxyConfigForm = Pick<
- ProxyConfig,
- | "listen_address"
- | "listen_port"
- | "max_retries"
- | "request_timeout"
- | "enable_logging"
->;
-
-const createProxyConfigSchema = (t: TFunction) => {
- const requestTimeoutSchema = z
- .number()
- .min(
- 0,
- t("proxy.settings.validation.timeoutNonNegative", {
- defaultValue: "超时时间不能为负数",
- }),
- )
- .max(
- 600,
- t("proxy.settings.validation.timeoutMax", {
- defaultValue: "超时时间最多600秒",
- }),
- )
- .refine((value) => value === 0 || value >= 10, {
- message: t("proxy.settings.validation.timeoutRange", {
- defaultValue: "请输入 0 或 10-600 之间的数值",
- }),
- });
-
- return z.object({
- listen_address: z.string().regex(
- /^(\d{1,3}\.){3}\d{1,3}$/,
- t("proxy.settings.validation.addressInvalid", {
- defaultValue: "请输入有效的IP地址",
- }),
- ),
- listen_port: z
- .number()
- .min(
- 1024,
- t("proxy.settings.validation.portMin", {
- defaultValue: "端口必须大于1024",
- }),
- )
- .max(
- 65535,
- t("proxy.settings.validation.portMax", {
- defaultValue: "端口必须小于65535",
- }),
- ),
- max_retries: z
- .number()
- .min(
- 0,
- t("proxy.settings.validation.retryMin", {
- defaultValue: "重试次数不能为负",
- }),
- )
- .max(
- 10,
- t("proxy.settings.validation.retryMax", {
- defaultValue: "重试次数不能超过10",
- }),
- ),
- request_timeout: requestTimeoutSchema,
- enable_logging: z.boolean(),
- });
-};
-
-interface ProxySettingsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-export function ProxySettingsDialog({
- open,
- onOpenChange,
-}: ProxySettingsDialogProps) {
- const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
- const { t } = useTranslation();
- const schema = useMemo(() => createProxyConfigSchema(t), [t]);
-
- const closePanel = () => onOpenChange(false);
-
- const form = useForm
({
- resolver: zodResolver(schema),
- defaultValues: {
- listen_address: "127.0.0.1",
- listen_port: 5000,
- max_retries: 3,
- request_timeout: 300,
- enable_logging: true,
- },
- });
-
- // 当配置加载完成后更新表单
- useEffect(() => {
- if (config) {
- form.reset({
- listen_address: config.listen_address,
- listen_port: config.listen_port,
- max_retries: config.max_retries,
- request_timeout: config.request_timeout,
- enable_logging: config.enable_logging,
- });
- }
- }, [config, form]);
-
- const onSubmit = async (data: ProxyConfigForm) => {
- try {
- await updateConfig(data);
- closePanel();
- } catch (error) {
- console.error("Save config failed:", error);
- }
- };
-
- const formId = "proxy-settings-form";
-
- return (
-
-
- {t("common.cancel", { defaultValue: "取消" })}
-
-
- {isUpdating
- ? t("common.saving", { defaultValue: "保存中..." })
- : t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
-
- >
- }
- >
-
-
- {t("proxy.settings.description", {
- defaultValue:
- "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
- })}
-
-
-
-
- {t("proxy.settings.alert.autoApply", {
- defaultValue:
- "保存后将自动同步到正在运行的代理服务,无需手动重启。",
- })}
-
-
-
-
-
-
-
- );
-}
diff --git a/src/components/proxy/index.ts b/src/components/proxy/index.ts
index b645228d..0abc6540 100644
--- a/src/components/proxy/index.ts
+++ b/src/components/proxy/index.ts
@@ -3,4 +3,3 @@
*/
export { ProxyPanel } from "./ProxyPanel";
-export { ProxySettingsDialog } from "./ProxySettingsDialog";
diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx
index 30481bd3..44c538af 100644
--- a/src/components/settings/SettingsPage.tsx
+++ b/src/components/settings/SettingsPage.tsx
@@ -384,47 +384,89 @@ export function SettingsPage({
)}
- {/* 故障转移队列管理 - 每个应用独立 */}
-
-
-
- {t("proxy.failoverQueue.title")}
-
-
- {t("proxy.failoverQueue.description")}
-
-
-
-
- Claude
- Codex
- Gemini
-
-
+ {/* 故障转移设置 - 按应用分组 */}
+
+
+ Claude
+ Codex
+ Gemini
+
+
+
+
+
+ {t("proxy.failoverQueue.title")}
+
+
+ {t("proxy.failoverQueue.description")}
+
+
-
-
+
+
+
+
+
+
+
+ {t("proxy.failoverQueue.title")}
+
+
+ {t("proxy.failoverQueue.description")}
+
+
-
-
+
+
+
+
+
+
+
+ {t("proxy.failoverQueue.title")}
+
+
+ {t("proxy.failoverQueue.description")}
+
+
-
-
-
-
- {/* 熔断器配置 - 全局共享 */}
-
+
+
+
+
diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx
index 2566c92b..2b199bc1 100644
--- a/src/components/skills/SkillsPage.tsx
+++ b/src/components/skills/SkillsPage.tsx
@@ -231,10 +231,10 @@ export const SkillsPage = forwardRef
(
) : skills.length === 0 ? (
-
+
{t("skills.empty")}
-
+
{t("skills.emptyDescription")}
(
{/* 技能列表或无结果提示 */}
{filteredSkills.length === 0 ? (
-
+
{t("skills.noResults")}
-
+
{t("skills.emptyDescription")}
diff --git a/src/components/universal/UniversalProviderCard.tsx b/src/components/universal/UniversalProviderCard.tsx
new file mode 100644
index 00000000..b75bb2ef
--- /dev/null
+++ b/src/components/universal/UniversalProviderCard.tsx
@@ -0,0 +1,115 @@
+import { useTranslation } from "react-i18next";
+import { Edit2, Trash2, RefreshCw, Globe } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { ProviderIcon } from "@/components/ProviderIcon";
+import type { UniversalProvider } from "@/types";
+
+interface UniversalProviderCardProps {
+ provider: UniversalProvider;
+ onEdit: (provider: UniversalProvider) => void;
+ onDelete: (id: string) => void;
+ onSync: (id: string) => void;
+}
+
+export function UniversalProviderCard({
+ provider,
+ onEdit,
+ onDelete,
+ onSync,
+}: UniversalProviderCardProps) {
+ const { t } = useTranslation();
+
+ // 获取启用的应用列表
+ const enabledApps: string[] = [
+ provider.apps.claude ? "Claude" : null,
+ provider.apps.codex ? "Codex" : null,
+ provider.apps.gemini ? "Gemini" : null,
+ ].filter((app): app is string => app !== null);
+
+ return (
+
+ {/* 头部:图标和名称 */}
+
+
+
+
+
{provider.name}
+
+ {provider.providerType}
+
+
+
+
+ {/* 操作按钮 */}
+
+ onSync(provider.id)}
+ title={t("universalProvider.sync", { defaultValue: "同步到应用" })}
+ >
+
+
+ onEdit(provider)}
+ title={t("common.edit", { defaultValue: "编辑" })}
+ >
+
+
+ onDelete(provider.id)}
+ title={t("common.delete", { defaultValue: "删除" })}
+ >
+
+
+
+
+
+ {/* 配置信息 */}
+
+ {/* Base URL */}
+
+
+
+ {provider.baseUrl || "-"}
+
+
+
+ {/* 启用的应用 */}
+
+ {enabledApps.map((app) => (
+
+ {app}
+
+ ))}
+ {enabledApps.length === 0 && (
+
+ {t("universalProvider.noAppsEnabled", {
+ defaultValue: "未启用任何应用",
+ })}
+
+ )}
+
+
+
+ {/* 备注 */}
+ {provider.notes && (
+
+ {provider.notes}
+
+ )}
+
+ );
+}
diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx
new file mode 100644
index 00000000..e586092c
--- /dev/null
+++ b/src/components/universal/UniversalProviderFormModal.tsx
@@ -0,0 +1,718 @@
+import { useState, useEffect, useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Eye, EyeOff, RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { FullScreenPanel } from "@/components/common/FullScreenPanel";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import { ProviderIcon } from "@/components/ProviderIcon";
+import JsonEditor from "@/components/JsonEditor";
+import type { UniversalProvider, UniversalProviderModels } from "@/types";
+import {
+ universalProviderPresets,
+ createUniversalProviderFromPreset,
+ type UniversalProviderPreset,
+} from "@/config/universalProviderPresets";
+
+interface UniversalProviderFormModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: (provider: UniversalProvider) => void;
+ onSaveAndSync?: (provider: UniversalProvider) => void;
+ editingProvider?: UniversalProvider | null;
+ initialPreset?: UniversalProviderPreset | null;
+}
+
+export function UniversalProviderFormModal({
+ isOpen,
+ onClose,
+ onSave,
+ onSaveAndSync,
+ editingProvider,
+ initialPreset,
+}: UniversalProviderFormModalProps) {
+ const { t } = useTranslation();
+ const isEditMode = !!editingProvider;
+
+ // 表单状态
+ const [selectedPreset, setSelectedPreset] =
+ useState(null);
+ const [name, setName] = useState("");
+ const [baseUrl, setBaseUrl] = useState("");
+ const [apiKey, setApiKey] = useState("");
+ const [showApiKey, setShowApiKey] = useState(false);
+ const [websiteUrl, setWebsiteUrl] = useState("");
+ const [notes, setNotes] = useState("");
+
+ // 应用启用状态
+ const [claudeEnabled, setClaudeEnabled] = useState(true);
+ const [codexEnabled, setCodexEnabled] = useState(true);
+ const [geminiEnabled, setGeminiEnabled] = useState(true);
+
+ // 模型配置
+ const [models, setModels] = useState({});
+
+ // 保存并同步确认弹窗
+ const [syncConfirmOpen, setSyncConfirmOpen] = useState(false);
+ const [pendingProvider, setPendingProvider] =
+ useState(null);
+
+ // 初始化表单
+ useEffect(() => {
+ if (editingProvider) {
+ // 编辑模式:加载现有数据
+ setName(editingProvider.name);
+ setBaseUrl(editingProvider.baseUrl);
+ setApiKey(editingProvider.apiKey);
+ setWebsiteUrl(editingProvider.websiteUrl || "");
+ setNotes(editingProvider.notes || "");
+ setClaudeEnabled(editingProvider.apps.claude);
+ setCodexEnabled(editingProvider.apps.codex);
+ setGeminiEnabled(editingProvider.apps.gemini);
+ setModels(editingProvider.models || {});
+
+ // 尝试匹配预设
+ const preset = universalProviderPresets.find(
+ (p) => p.providerType === editingProvider.providerType,
+ );
+ setSelectedPreset(preset || null);
+ } else {
+ // 新建模式:使用传入的预设或默认选择第一个预设
+ const defaultPreset = initialPreset || universalProviderPresets[0];
+ setSelectedPreset(defaultPreset);
+ setName(defaultPreset.name);
+ setBaseUrl("");
+ setApiKey("");
+ setWebsiteUrl(defaultPreset.websiteUrl || "");
+ setNotes("");
+ setClaudeEnabled(defaultPreset.defaultApps.claude);
+ setCodexEnabled(defaultPreset.defaultApps.codex);
+ setGeminiEnabled(defaultPreset.defaultApps.gemini);
+ setModels(JSON.parse(JSON.stringify(defaultPreset.defaultModels)));
+ }
+ }, [editingProvider, initialPreset, isOpen]);
+
+ // 选择预设
+ const handlePresetSelect = useCallback(
+ (preset: UniversalProviderPreset) => {
+ setSelectedPreset(preset);
+ if (!isEditMode) {
+ setName(preset.name);
+ setClaudeEnabled(preset.defaultApps.claude);
+ setCodexEnabled(preset.defaultApps.codex);
+ setGeminiEnabled(preset.defaultApps.gemini);
+ setModels(JSON.parse(JSON.stringify(preset.defaultModels)));
+ }
+ },
+ [isEditMode],
+ );
+
+ // 更新模型配置
+ const updateModel = useCallback(
+ (app: "claude" | "codex" | "gemini", field: string, value: string) => {
+ setModels((prev) => ({
+ ...prev,
+ [app]: {
+ ...(prev[app] || {}),
+ [field]: value,
+ },
+ }));
+ },
+ [],
+ );
+
+ // 计算 Claude 配置 JSON 预览
+ const claudeConfigJson = useMemo(() => {
+ if (!claudeEnabled) return null;
+ const model = models.claude?.model || "claude-sonnet-4-20250514";
+ const haiku = models.claude?.haikuModel || "claude-haiku-4-20250514";
+ const sonnet = models.claude?.sonnetModel || "claude-sonnet-4-20250514";
+ const opus = models.claude?.opusModel || "claude-sonnet-4-20250514";
+ return {
+ env: {
+ ANTHROPIC_BASE_URL: baseUrl,
+ ANTHROPIC_AUTH_TOKEN: apiKey,
+ ANTHROPIC_MODEL: model,
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: haiku,
+ ANTHROPIC_DEFAULT_SONNET_MODEL: sonnet,
+ ANTHROPIC_DEFAULT_OPUS_MODEL: opus,
+ },
+ };
+ }, [claudeEnabled, baseUrl, apiKey, models.claude]);
+
+ // 计算 Codex 配置 JSON 预览
+ const codexConfigJson = useMemo(() => {
+ if (!codexEnabled) return null;
+ const model = models.codex?.model || "gpt-4o";
+ const reasoningEffort = models.codex?.reasoningEffort || "high";
+ // 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API)
+ const codexBaseUrl = baseUrl.endsWith("/v1")
+ ? baseUrl
+ : `${baseUrl.replace(/\/+$/, "")}/v1`;
+ const configToml = `model_provider = "newapi"
+model = "${model}"
+model_reasoning_effort = "${reasoningEffort}"
+disable_response_storage = true
+
+[model_providers.newapi]
+name = "NewAPI"
+base_url = "${codexBaseUrl}"
+wire_api = "responses"
+requires_openai_auth = true`;
+ return {
+ auth: {
+ OPENAI_API_KEY: apiKey,
+ },
+ config: configToml,
+ };
+ }, [codexEnabled, baseUrl, apiKey, models.codex]);
+
+ // 计算 Gemini 配置 JSON 预览
+ const geminiConfigJson = useMemo(() => {
+ if (!geminiEnabled) return null;
+ const model = models.gemini?.model || "gemini-2.5-pro";
+ return {
+ env: {
+ GOOGLE_GEMINI_BASE_URL: baseUrl,
+ GEMINI_API_KEY: apiKey,
+ GEMINI_MODEL: model,
+ },
+ };
+ }, [geminiEnabled, baseUrl, apiKey, models.gemini]);
+
+ // 提交表单
+ const handleSubmit = useCallback(() => {
+ if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {
+ return;
+ }
+
+ const provider: UniversalProvider = editingProvider
+ ? {
+ ...editingProvider,
+ name: name.trim(),
+ baseUrl: baseUrl.trim(),
+ apiKey: apiKey.trim(),
+ websiteUrl: websiteUrl.trim() || undefined,
+ notes: notes.trim() || undefined,
+ apps: {
+ claude: claudeEnabled,
+ codex: codexEnabled,
+ gemini: geminiEnabled,
+ },
+ models,
+ }
+ : createUniversalProviderFromPreset(
+ selectedPreset || universalProviderPresets[0],
+ crypto.randomUUID(),
+ baseUrl.trim(),
+ apiKey.trim(),
+ name.trim(),
+ );
+
+ // 如果是新建,更新应用启用状态和模型
+ if (!editingProvider) {
+ provider.apps = {
+ claude: claudeEnabled,
+ codex: codexEnabled,
+ gemini: geminiEnabled,
+ };
+ provider.models = models;
+ provider.websiteUrl = websiteUrl.trim() || undefined;
+ provider.notes = notes.trim() || undefined;
+ }
+
+ onSave(provider);
+ onClose();
+ }, [
+ editingProvider,
+ name,
+ baseUrl,
+ apiKey,
+ websiteUrl,
+ notes,
+ claudeEnabled,
+ codexEnabled,
+ geminiEnabled,
+ models,
+ selectedPreset,
+ onSave,
+ onClose,
+ ]);
+
+ // 构建 provider 对象的辅助函数
+ const buildProvider = useCallback((): UniversalProvider | null => {
+ if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {
+ return null;
+ }
+
+ const provider: UniversalProvider = editingProvider
+ ? {
+ ...editingProvider,
+ name: name.trim(),
+ baseUrl: baseUrl.trim(),
+ apiKey: apiKey.trim(),
+ websiteUrl: websiteUrl.trim() || undefined,
+ notes: notes.trim() || undefined,
+ apps: {
+ claude: claudeEnabled,
+ codex: codexEnabled,
+ gemini: geminiEnabled,
+ },
+ models,
+ }
+ : createUniversalProviderFromPreset(
+ selectedPreset || universalProviderPresets[0],
+ crypto.randomUUID(),
+ baseUrl.trim(),
+ apiKey.trim(),
+ name.trim(),
+ );
+
+ // 如果是新建,更新应用启用状态和模型
+ if (!editingProvider) {
+ provider.apps = {
+ claude: claudeEnabled,
+ codex: codexEnabled,
+ gemini: geminiEnabled,
+ };
+ provider.models = models;
+ provider.websiteUrl = websiteUrl.trim() || undefined;
+ provider.notes = notes.trim() || undefined;
+ }
+
+ return provider;
+ }, [
+ editingProvider,
+ name,
+ baseUrl,
+ apiKey,
+ websiteUrl,
+ notes,
+ claudeEnabled,
+ codexEnabled,
+ geminiEnabled,
+ models,
+ selectedPreset,
+ ]);
+
+ // 打开保存并同步确认弹窗
+ const handleSaveAndSyncClick = useCallback(() => {
+ const provider = buildProvider();
+ if (!provider || !onSaveAndSync) return;
+
+ setPendingProvider(provider);
+ setSyncConfirmOpen(true);
+ }, [buildProvider, onSaveAndSync]);
+
+ // 确认保存并同步
+ const confirmSaveAndSync = useCallback(() => {
+ if (!pendingProvider || !onSaveAndSync) return;
+
+ onSaveAndSync(pendingProvider);
+ setSyncConfirmOpen(false);
+ setPendingProvider(null);
+ onClose();
+ }, [pendingProvider, onSaveAndSync, onClose]);
+
+ const footer = (
+ <>
+
+ {t("common.cancel", { defaultValue: "取消" })}
+
+ {isEditMode && onSaveAndSync ? (
+
+
+ {t("universalProvider.saveAndSync", { defaultValue: "保存并同步" })}
+
+ ) : (
+
+ {t("common.add", { defaultValue: "添加" })}
+
+ )}
+ >
+ );
+
+ return (
+
+
+ {/* 预设选择(仅新建模式) */}
+ {!isEditMode && (
+
+
+ {t("universalProvider.selectPreset", {
+ defaultValue: "选择预设类型",
+ })}
+
+
+ {universalProviderPresets.map((preset) => (
+
handlePresetSelect(preset)}
+ className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ selectedPreset?.providerType === preset.providerType
+ ? "bg-primary text-primary-foreground"
+ : "bg-accent text-muted-foreground hover:bg-accent/80"
+ }`}
+ >
+
+ {preset.name}
+
+ ))}
+
+ {selectedPreset?.description && (
+
+ {selectedPreset.description}
+
+ )}
+
+ )}
+
+ {/* 基本信息 */}
+
+
+
+ {t("universalProvider.name", { defaultValue: "名称" })}
+
+ setName(e.target.value)}
+ placeholder={t("universalProvider.namePlaceholder", {
+ defaultValue: "例如:我的 NewAPI",
+ })}
+ />
+
+
+
+
+ {t("universalProvider.baseUrl", { defaultValue: "API 地址" })}
+
+ setBaseUrl(e.target.value)}
+ placeholder="https://api.example.com"
+ />
+
+
+
+
+ {t("universalProvider.apiKey", { defaultValue: "API Key" })}
+
+
+ setApiKey(e.target.value)}
+ placeholder="sk-..."
+ className="pr-10"
+ />
+ setShowApiKey(!showApiKey)}
+ >
+ {showApiKey ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {t("universalProvider.websiteUrl", { defaultValue: "官网地址" })}
+
+ setWebsiteUrl(e.target.value)}
+ placeholder={t("universalProvider.websiteUrlPlaceholder", {
+ defaultValue: "https://example.com(可选,用于在列表中显示)",
+ })}
+ />
+
+
+
+
+ {t("universalProvider.notes", { defaultValue: "备注" })}
+
+ setNotes(e.target.value)}
+ placeholder={t("universalProvider.notesPlaceholder", {
+ defaultValue: "可选:添加备注信息",
+ })}
+ />
+
+
+
+ {/* 应用启用 */}
+
+
+ {t("universalProvider.enabledApps", { defaultValue: "启用的应用" })}
+
+
+
+
+ {/* 模型配置 */}
+
+
+ {t("universalProvider.modelConfig", { defaultValue: "模型配置" })}
+
+
+ {/* Claude 模型 */}
+ {claudeEnabled && (
+
+ )}
+
+ {/* Codex 模型 */}
+ {codexEnabled && (
+
+ )}
+
+ {/* Gemini 模型 */}
+ {geminiEnabled && (
+
+
+
+
+ {t("universalProvider.model", { defaultValue: "模型" })}
+
+
+ updateModel("gemini", "model", e.target.value)
+ }
+ placeholder="gemini-2.5-pro"
+ />
+
+
+ )}
+
+
+ {/* 配置 JSON 预览 */}
+ {isEditMode && (claudeEnabled || codexEnabled || geminiEnabled) && (
+
+
+ {t("universalProvider.configJsonPreview", {
+ defaultValue: "配置 JSON 预览",
+ })}
+
+
+ {t("universalProvider.configJsonPreviewHint", {
+ defaultValue:
+ "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)",
+ })}
+
+
+ {/* Claude JSON */}
+ {claudeConfigJson && (
+
+
+
{}}
+ height={180}
+ />
+
+ )}
+
+ {/* Codex JSON */}
+ {codexConfigJson && (
+
+
+
{}}
+ height={280}
+ />
+
+ )}
+
+ {/* Gemini JSON */}
+ {geminiConfigJson && (
+
+
+
{}}
+ height={140}
+ />
+
+ )}
+
+ )}
+
+
+ {/* 保存并同步确认弹窗 */}
+ {
+ setSyncConfirmOpen(false);
+ setPendingProvider(null);
+ }}
+ />
+
+ );
+}
diff --git a/src/components/universal/UniversalProviderPanel.tsx b/src/components/universal/UniversalProviderPanel.tsx
new file mode 100644
index 00000000..0a81a8ac
--- /dev/null
+++ b/src/components/universal/UniversalProviderPanel.tsx
@@ -0,0 +1,288 @@
+import { useState, useCallback, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { Layers } from "lucide-react";
+import { toast } from "sonner";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import { UniversalProviderCard } from "./UniversalProviderCard";
+import { UniversalProviderFormModal } from "./UniversalProviderFormModal";
+import { universalProvidersApi } from "@/lib/api";
+import type { UniversalProvider, UniversalProvidersMap } from "@/types";
+
+export function UniversalProviderPanel() {
+ const { t } = useTranslation();
+
+ // 状态
+ const [providers, setProviders] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [isFormOpen, setIsFormOpen] = useState(false);
+ const [editingProvider, setEditingProvider] =
+ useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState<{
+ open: boolean;
+ id: string;
+ name: string;
+ }>({ open: false, id: "", name: "" });
+ const [syncConfirm, setSyncConfirm] = useState<{
+ open: boolean;
+ id: string;
+ name: string;
+ }>({ open: false, id: "", name: "" });
+
+ // 加载数据
+ const loadProviders = useCallback(async () => {
+ try {
+ setLoading(true);
+ const data = await universalProvidersApi.getAll();
+ setProviders(data);
+ } catch (error) {
+ console.error("Failed to load universal providers:", error);
+ toast.error(
+ t("universalProvider.loadError", {
+ defaultValue: "加载统一供应商失败",
+ }),
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [t]);
+
+ useEffect(() => {
+ loadProviders();
+ }, [loadProviders]);
+
+ // 添加/编辑供应商
+ const handleSave = useCallback(
+ async (provider: UniversalProvider) => {
+ try {
+ await universalProvidersApi.upsert(provider);
+
+ // 新建模式下自动同步到各应用
+ if (!editingProvider) {
+ await universalProvidersApi.sync(provider.id);
+ }
+
+ toast.success(
+ editingProvider
+ ? t("universalProvider.updated", {
+ defaultValue: "统一供应商已更新",
+ })
+ : t("universalProvider.addedAndSynced", {
+ defaultValue: "统一供应商已添加并同步",
+ }),
+ );
+ loadProviders();
+ setEditingProvider(null);
+ } catch (error) {
+ console.error("Failed to save universal provider:", error);
+ toast.error(
+ t("universalProvider.saveError", {
+ defaultValue: "保存统一供应商失败",
+ }),
+ );
+ }
+ },
+ [editingProvider, loadProviders, t],
+ );
+
+ // 保存并同步供应商
+ const handleSaveAndSync = useCallback(
+ async (provider: UniversalProvider) => {
+ try {
+ await universalProvidersApi.upsert(provider);
+ await universalProvidersApi.sync(provider.id);
+ toast.success(
+ t("universalProvider.savedAndSynced", {
+ defaultValue: "已保存并同步到所有应用",
+ }),
+ );
+ loadProviders();
+ setEditingProvider(null);
+ } catch (error) {
+ console.error("Failed to save and sync universal provider:", error);
+ toast.error(
+ t("universalProvider.saveAndSyncError", {
+ defaultValue: "保存并同步失败",
+ }),
+ );
+ }
+ },
+ [loadProviders, t],
+ );
+
+ // 删除供应商
+ const handleDelete = useCallback(async () => {
+ if (!deleteConfirm.id) return;
+
+ try {
+ await universalProvidersApi.delete(deleteConfirm.id);
+ toast.success(
+ t("universalProvider.deleted", { defaultValue: "统一供应商已删除" }),
+ );
+ loadProviders();
+ } catch (error) {
+ console.error("Failed to delete universal provider:", error);
+ toast.error(
+ t("universalProvider.deleteError", {
+ defaultValue: "删除统一供应商失败",
+ }),
+ );
+ } finally {
+ setDeleteConfirm({ open: false, id: "", name: "" });
+ }
+ }, [deleteConfirm.id, loadProviders, t]);
+
+ // 同步供应商
+ const handleSync = useCallback(async () => {
+ if (!syncConfirm.id) return;
+
+ try {
+ await universalProvidersApi.sync(syncConfirm.id);
+ toast.success(
+ t("universalProvider.synced", { defaultValue: "已同步到所有应用" }),
+ );
+ } catch (error) {
+ console.error("Failed to sync universal provider:", error);
+ toast.error(
+ t("universalProvider.syncError", {
+ defaultValue: "同步统一供应商失败",
+ }),
+ );
+ } finally {
+ setSyncConfirm({ open: false, id: "", name: "" });
+ }
+ }, [syncConfirm.id, t]);
+
+ // 打开同步确认
+ const handleSyncClick = useCallback(
+ (id: string) => {
+ const provider = providers[id];
+ setSyncConfirm({
+ open: true,
+ id,
+ name: provider?.name || id,
+ });
+ },
+ [providers],
+ );
+
+ // 打开编辑
+ const handleEdit = useCallback((provider: UniversalProvider) => {
+ setEditingProvider(provider);
+ setIsFormOpen(true);
+ }, []);
+
+ // 打开删除确认
+ const handleDeleteClick = useCallback(
+ (id: string) => {
+ const provider = providers[id];
+ setDeleteConfirm({
+ open: true,
+ id,
+ name: provider?.name || id,
+ });
+ },
+ [providers],
+ );
+
+ const providerList = Object.values(providers);
+
+ return (
+
+ {/* 头部 */}
+
+
+
+ {t("universalProvider.title", { defaultValue: "统一供应商" })}
+
+
+ {providerList.length}
+
+
+
+ {/* 描述 */}
+
+ {t("universalProvider.description", {
+ defaultValue:
+ "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
+ })}
+
+
+ {/* 供应商列表 */}
+ {loading ? (
+
+ ) : providerList.length === 0 ? (
+
+
+
+ {t("universalProvider.empty", {
+ defaultValue: "还没有统一供应商",
+ })}
+
+
+ {t("universalProvider.emptyHint", {
+ defaultValue: "点击下方「添加统一供应商」按钮创建一个",
+ })}
+
+
+ ) : (
+
+ {providerList.map((provider) => (
+
+ ))}
+
+ )}
+
+ {/* 表单模态框 */}
+
{
+ setIsFormOpen(false);
+ setEditingProvider(null);
+ }}
+ onSave={handleSave}
+ onSaveAndSync={handleSaveAndSync}
+ editingProvider={editingProvider}
+ />
+
+ {/* 删除确认对话框 */}
+ setDeleteConfirm({ open: false, id: "", name: "" })}
+ />
+
+ {/* 同步确认对话框 */}
+ setSyncConfirm({ open: false, id: "", name: "" })}
+ />
+
+ );
+}
diff --git a/src/components/universal/index.ts b/src/components/universal/index.ts
new file mode 100644
index 00000000..7a09c9a9
--- /dev/null
+++ b/src/components/universal/index.ts
@@ -0,0 +1,3 @@
+export { UniversalProviderPanel } from "./UniversalProviderPanel";
+export { UniversalProviderCard } from "./UniversalProviderCard";
+export { UniversalProviderFormModal } from "./UniversalProviderFormModal";
diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts
index 851e9e79..8de5067d 100644
--- a/src/config/claudeProviderPresets.ts
+++ b/src/config/claudeProviderPresets.ts
@@ -391,4 +391,22 @@ export const providerPresets: ProviderPreset[] = [
icon: "openrouter",
iconColor: "#6566F1",
},
+ {
+ name: "Xiaomi MiMo",
+ websiteUrl: "https://platform.xiaomimimo.com",
+ apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
+ settingsConfig: {
+ env: {
+ ANTHROPIC_BASE_URL: "https://api.xiaomimimo.com/anthropic",
+ ANTHROPIC_AUTH_TOKEN: "",
+ ANTHROPIC_MODEL: "mimo-v2-flash",
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2-flash",
+ ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2-flash",
+ ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2-flash",
+ },
+ },
+ category: "cn_official",
+ icon: "xiaomimimo",
+ iconColor: "#000000",
+ },
];
diff --git a/src/config/universalProviderPresets.ts b/src/config/universalProviderPresets.ts
new file mode 100644
index 00000000..5bb85433
--- /dev/null
+++ b/src/config/universalProviderPresets.ts
@@ -0,0 +1,131 @@
+/**
+ * 统一供应商(Universal Provider)预设配置
+ *
+ * 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
+ * 适用于 NewAPI 等支持多种协议的 API 网关。
+ */
+
+import type {
+ UniversalProvider,
+ UniversalProviderApps,
+ UniversalProviderModels,
+} from "@/types";
+
+/**
+ * 统一供应商预设接口
+ */
+export interface UniversalProviderPreset {
+ /** 预设名称 */
+ name: string;
+ /** 供应商类型标识 */
+ providerType: string;
+ /** 默认启用的应用 */
+ defaultApps: UniversalProviderApps;
+ /** 默认模型配置 */
+ defaultModels: UniversalProviderModels;
+ /** 网站链接 */
+ websiteUrl?: string;
+ /** 图标名称 */
+ icon?: string;
+ /** 图标颜色 */
+ iconColor?: string;
+ /** 描述 */
+ description?: string;
+ /** 是否为自定义模板(允许用户完全自定义) */
+ isCustomTemplate?: boolean;
+}
+
+/**
+ * NewAPI 默认模型配置
+ */
+const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
+ claude: {
+ model: "claude-sonnet-4-20250514",
+ haikuModel: "claude-haiku-4-20250514",
+ sonnetModel: "claude-sonnet-4-20250514",
+ opusModel: "claude-sonnet-4-20250514",
+ },
+ codex: {
+ model: "gpt-4o",
+ reasoningEffort: "high",
+ },
+ gemini: {
+ model: "gemini-2.5-pro",
+ },
+};
+
+/**
+ * 统一供应商预设列表
+ */
+export const universalProviderPresets: UniversalProviderPreset[] = [
+ {
+ name: "NewAPI",
+ providerType: "newapi",
+ defaultApps: {
+ claude: true,
+ codex: true,
+ gemini: true,
+ },
+ defaultModels: NEWAPI_DEFAULT_MODELS,
+ websiteUrl: "https://www.newapi.pro",
+ icon: "newapi",
+ iconColor: "#00A67E",
+ description:
+ "NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
+ },
+ {
+ name: "自定义网关",
+ providerType: "custom_gateway",
+ defaultApps: {
+ claude: true,
+ codex: true,
+ gemini: true,
+ },
+ defaultModels: NEWAPI_DEFAULT_MODELS,
+ icon: "openai",
+ iconColor: "#6366F1",
+ description: "自定义配置的 API 网关",
+ isCustomTemplate: true,
+ },
+];
+
+/**
+ * 根据预设创建统一供应商
+ */
+export function createUniversalProviderFromPreset(
+ preset: UniversalProviderPreset,
+ id: string,
+ baseUrl: string,
+ apiKey: string,
+ customName?: string,
+): UniversalProvider {
+ return {
+ id,
+ name: customName || preset.name,
+ providerType: preset.providerType,
+ apps: { ...preset.defaultApps },
+ baseUrl,
+ apiKey,
+ models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
+ websiteUrl: preset.websiteUrl,
+ icon: preset.icon,
+ iconColor: preset.iconColor,
+ createdAt: Date.now(),
+ };
+}
+
+/**
+ * 获取预设的显示名称(用于 UI)
+ */
+export function getPresetDisplayName(preset: UniversalProviderPreset): string {
+ return preset.name;
+}
+
+/**
+ * 根据类型查找预设
+ */
+export function findPresetByType(
+ providerType: string,
+): UniversalProviderPreset | undefined {
+ return universalProviderPresets.find((p) => p.providerType === providerType);
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 051d4aff..97f6873a 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -66,6 +66,8 @@
"exitEditMode": "Exit Edit Mode"
},
"provider": {
+ "tabProvider": "Provider",
+ "tabUniversal": "Universal",
"noProviders": "No providers added yet",
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
"currentlyUsing": "Currently Using",
@@ -340,6 +342,10 @@
"visitWebsite": "Visit {{url}}",
"anthropicModel": "Main Model",
"anthropicSmallFastModel": "Fast Model",
+ "anthropicReasoningModel": "Reasoning Model (Thinking)",
+ "reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514",
+ "openrouterCompatMode": "OpenRouter Compatibility Mode",
+ "openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
"anthropicDefaultHaikuModel": "Default Haiku Model",
"anthropicDefaultSonnetModel": "Default Sonnet Model",
"anthropicDefaultOpusModel": "Default Opus Model",
@@ -981,12 +987,85 @@
}
},
"settings": {
+ "title": "Proxy Service Settings",
+ "description": "Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.",
+ "alert": {
+ "autoApply": "Changes will be automatically synced to the running proxy service without manual restart."
+ },
+ "basic": {
+ "title": "Basic Settings",
+ "description": "Configure proxy service listening address and port."
+ },
+ "advanced": {
+ "title": "Advanced Parameters",
+ "description": "Control request stability and logging."
+ },
+ "timeout": {
+ "title": "Timeout Settings",
+ "description": "Configure timeout for streaming and non-streaming requests."
+ },
+ "fields": {
+ "listenAddress": {
+ "label": "Listen Address",
+ "placeholder": "127.0.0.1",
+ "description": "IP address the proxy server listens on (recommended: 127.0.0.1)"
+ },
+ "listenPort": {
+ "label": "Listen Port",
+ "placeholder": "5000",
+ "description": "Port number the proxy server listens on (1024 ~ 65535)"
+ },
+ "maxRetries": {
+ "label": "Max Retries",
+ "placeholder": "3",
+ "description": "Number of retries on request failure (0 ~ 10)"
+ },
+ "requestTimeout": {
+ "label": "Request Timeout (sec)",
+ "placeholder": "0 (unlimited) or 300",
+ "description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)"
+ },
+ "enableLogging": {
+ "label": "Enable Logging",
+ "description": "Log all proxy requests for troubleshooting"
+ },
+ "streamingFirstByteTimeout": {
+ "label": "Streaming First Byte Timeout (sec)",
+ "description": "Maximum time to wait for the first data chunk"
+ },
+ "streamingIdleTimeout": {
+ "label": "Streaming Idle Timeout (sec)",
+ "description": "Maximum interval between data chunks"
+ },
+ "nonStreamingTimeout": {
+ "label": "Non-Streaming Timeout (sec)",
+ "description": "Total timeout for non-streaming requests"
+ }
+ },
+ "validation": {
+ "addressInvalid": "Please enter a valid IP address",
+ "portMin": "Port must be greater than 1024",
+ "portMax": "Port must be less than 65535",
+ "retryMin": "Retry count cannot be negative",
+ "retryMax": "Retry count cannot exceed 10",
+ "timeoutNonNegative": "Timeout cannot be negative",
+ "timeoutMax": "Timeout cannot exceed 600 seconds",
+ "timeoutRange": "Please enter 0 or a value between 10-600",
+ "streamingTimeoutMin": "Timeout must be at least 5 seconds",
+ "streamingTimeoutMax": "Timeout cannot exceed 300 seconds"
+ },
+ "actions": {
+ "save": "Save Configuration"
+ },
"toast": {
"saved": "Proxy configuration saved",
"saveFailed": "Save failed: {{error}}"
}
},
"switchFailed": "Switch failed: {{error}}",
+ "failover": {
+ "proxyRequired": "Proxy service must be started to configure failover"
+ },
"failoverQueue": {
"title": "Failover Queue",
"description": "Manage failover order for each app's providers",
@@ -1013,7 +1092,7 @@
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
"timeout": "Recovery Wait Time (seconds)",
"timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)",
- "circuitBreakerSettings": "Circuit Breaker Advanced Settings",
+ "circuitBreakerSettings": "Circuit Breaker Settings",
"successThreshold": "Recovery Success Threshold",
"successThresholdHint": "Close circuit breaker after this many successes in half-open state",
"errorRate": "Error Rate Threshold (%)",
@@ -1042,5 +1121,65 @@
"timeout": "Timeout (seconds)",
"maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)"
+ },
+ "proxyConfig": {
+ "proxyEnabled": "Proxy Enabled",
+ "appTakeover": "Proxy Enabled",
+ "perAppConfig": "Per-App Config",
+ "circuitBreaker": "Circuit Breaker",
+ "circuitBreakerSettings": "Circuit Breaker Settings",
+ "failureThreshold": "Failure Threshold",
+ "successThreshold": "Success Threshold",
+ "recoveryTimeout": "Recovery Timeout",
+ "errorRateThreshold": "Error Rate Threshold",
+ "minRequests": "Min Requests",
+ "timeoutConfig": "Timeout Config",
+ "streamingFirstByte": "Streaming First Byte Timeout",
+ "streamingIdle": "Streaming Idle Timeout",
+ "nonStreaming": "Non-Streaming Timeout"
+ },
+ "universalProvider": {
+ "title": "Universal Provider",
+ "description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
+ "add": "Add Universal Provider",
+ "edit": "Edit Universal Provider",
+ "empty": "No universal providers yet",
+ "emptyHint": "Click the \"Add Universal Provider\" button below to create one",
+ "selectPreset": "Select Preset Type",
+ "name": "Name",
+ "namePlaceholder": "e.g., My NewAPI",
+ "baseUrl": "API URL",
+ "apiKey": "API Key",
+ "websiteUrl": "Website URL",
+ "websiteUrlPlaceholder": "https://example.com (optional, displayed in the list)",
+ "notes": "Notes",
+ "notesPlaceholder": "Optional: Add notes",
+ "enabledApps": "Enabled Apps",
+ "modelConfig": "Model Configuration",
+ "model": "Model",
+ "sync": "Sync to Apps",
+ "synced": "Synced to all apps",
+ "syncError": "Sync failed",
+ "noAppsEnabled": "No apps enabled",
+ "added": "Universal provider added",
+ "addedAndSynced": "Universal provider added and synced",
+ "updated": "Universal provider updated",
+ "deleted": "Universal provider deleted",
+ "addSuccess": "Universal provider added successfully",
+ "addFailed": "Failed to add universal provider",
+ "manage": "Manage",
+ "loadError": "Failed to load universal providers",
+ "saveError": "Failed to save universal provider",
+ "deleteError": "Failed to delete universal provider",
+ "deleteConfirmTitle": "Delete Universal Provider",
+ "deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This will also delete its generated provider configurations in each app.",
+ "syncConfirmTitle": "Sync Universal Provider",
+ "syncConfirmDescription": "Syncing \"{{name}}\" will overwrite the associated provider configurations in Claude, Codex, and Gemini. Do you want to continue?",
+ "syncConfirm": "Sync",
+ "saveAndSync": "Save & Sync",
+ "savedAndSynced": "Saved and synced to all apps",
+ "saveAndSyncError": "Failed to save and sync",
+ "configJsonPreview": "Config JSON Preview",
+ "configJsonPreviewHint": "The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)"
}
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 6554839b..1a27b471 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -66,6 +66,8 @@
"exitEditMode": "編集モードを終了"
},
"provider": {
+ "tabProvider": "プロバイダー",
+ "tabUniversal": "統一プロバイダー",
"noProviders": "まだプロバイダーがありません",
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
"currentlyUsing": "現在使用中",
@@ -340,6 +342,10 @@
"visitWebsite": "{{url}} を開く",
"anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル",
+ "anthropicReasoningModel": "推論モデル(Thinking)",
+ "reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
+ "openrouterCompatMode": "OpenRouter 互換モード",
+ "openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
@@ -981,12 +987,85 @@
}
},
"settings": {
+ "title": "プロキシサービス設定",
+ "description": "ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。",
+ "alert": {
+ "autoApply": "変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。"
+ },
+ "basic": {
+ "title": "基本設定",
+ "description": "プロキシサービスのリッスンアドレスとポートを設定します。"
+ },
+ "advanced": {
+ "title": "詳細パラメータ",
+ "description": "リクエストの安定性とログ記録を制御します。"
+ },
+ "timeout": {
+ "title": "タイムアウト設定",
+ "description": "ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。"
+ },
+ "fields": {
+ "listenAddress": {
+ "label": "リッスンアドレス",
+ "placeholder": "127.0.0.1",
+ "description": "プロキシサーバーがリッスンするIPアドレス(推奨: 127.0.0.1)"
+ },
+ "listenPort": {
+ "label": "リッスンポート",
+ "placeholder": "5000",
+ "description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535)"
+ },
+ "maxRetries": {
+ "label": "最大リトライ回数",
+ "placeholder": "3",
+ "description": "リクエスト失敗時のリトライ回数(0 ~ 10)"
+ },
+ "requestTimeout": {
+ "label": "リクエストタイムアウト(秒)",
+ "placeholder": "0(無制限)または 300",
+ "description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)"
+ },
+ "enableLogging": {
+ "label": "ログ記録を有効化",
+ "description": "トラブルシューティングのためにすべてのプロキシリクエストを記録"
+ },
+ "streamingFirstByteTimeout": {
+ "label": "ストリーミング初回バイトタイムアウト(秒)",
+ "description": "最初のデータチャンクを待つ最大時間"
+ },
+ "streamingIdleTimeout": {
+ "label": "ストリーミングアイドルタイムアウト(秒)",
+ "description": "データチャンク間の最大間隔"
+ },
+ "nonStreamingTimeout": {
+ "label": "非ストリーミングタイムアウト(秒)",
+ "description": "非ストリーミングリクエストの総タイムアウト"
+ }
+ },
+ "validation": {
+ "addressInvalid": "有効なIPアドレスを入力してください",
+ "portMin": "ポートは1024より大きい必要があります",
+ "portMax": "ポートは65535より小さい必要があります",
+ "retryMin": "リトライ回数は負の値にできません",
+ "retryMax": "リトライ回数は10を超えることはできません",
+ "timeoutNonNegative": "タイムアウトは負の値にできません",
+ "timeoutMax": "タイムアウトは600秒を超えることはできません",
+ "timeoutRange": "0または10-600の間の値を入力してください",
+ "streamingTimeoutMin": "タイムアウトは少なくとも5秒必要です",
+ "streamingTimeoutMax": "タイムアウトは300秒を超えることはできません"
+ },
+ "actions": {
+ "save": "設定を保存"
+ },
"toast": {
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}"
}
},
"switchFailed": "切り替えに失敗しました: {{error}}",
+ "failover": {
+ "proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります"
+ },
"failoverQueue": {
"title": "フェイルオーバーキュー",
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
@@ -1013,7 +1092,7 @@
"failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)",
"timeout": "回復待ち時間(秒)",
"timeoutHint": "サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)",
- "circuitBreakerSettings": "サーキットブレーカー詳細設定",
+ "circuitBreakerSettings": "サーキットブレーカー設定",
"successThreshold": "回復成功しきい値",
"successThresholdHint": "半開状態でこの回数成功するとサーキットブレーカーが閉じます",
"errorRate": "エラー率しきい値 (%)",
@@ -1042,5 +1121,64 @@
"timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)"
+ },
+ "proxyConfig": {
+ "proxyEnabled": "プロキシ有効",
+ "appTakeover": "プロキシ有効",
+ "perAppConfig": "アプリ別設定",
+ "circuitBreaker": "サーキットブレーカー",
+ "circuitBreakerSettings": "サーキットブレーカー設定",
+ "failureThreshold": "失敗閾値",
+ "successThreshold": "回復閾値",
+ "recoveryTimeout": "回復待機時間",
+ "errorRateThreshold": "エラー率閾値",
+ "minRequests": "最小リクエスト数",
+ "timeoutConfig": "タイムアウト設定",
+ "streamingFirstByte": "ストリーミング初回バイトタイムアウト",
+ "streamingIdle": "ストリーミングアイドルタイムアウト",
+ "nonStreaming": "非ストリーミングタイムアウト"
+ },
+ "universalProvider": {
+ "title": "統合プロバイダー",
+ "description": "統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。",
+ "add": "統合プロバイダーを追加",
+ "edit": "統合プロバイダーを編集",
+ "empty": "統合プロバイダーがありません",
+ "emptyHint": "下の「統合プロバイダーを追加」ボタンをクリックして作成してください",
+ "selectPreset": "プリセットタイプを選択",
+ "name": "名前",
+ "namePlaceholder": "例:私の NewAPI",
+ "baseUrl": "API URL",
+ "apiKey": "API キー",
+ "websiteUrl": "ウェブサイト URL",
+ "websiteUrlPlaceholder": "https://example.com(オプション、リストに表示されます)",
+ "notes": "メモ",
+ "notesPlaceholder": "オプション:メモを追加",
+ "enabledApps": "有効なアプリ",
+ "modelConfig": "モデル設定",
+ "model": "モデル",
+ "sync": "アプリに同期",
+ "synced": "すべてのアプリに同期しました",
+ "syncError": "同期に失敗しました",
+ "noAppsEnabled": "有効なアプリがありません",
+ "added": "統合プロバイダーを追加しました",
+ "updated": "統合プロバイダーを更新しました",
+ "deleted": "統合プロバイダーを削除しました",
+ "addSuccess": "統合プロバイダーを追加しました",
+ "addFailed": "統合プロバイダーの追加に失敗しました",
+ "manage": "管理",
+ "loadError": "統合プロバイダーの読み込みに失敗しました",
+ "saveError": "統合プロバイダーの保存に失敗しました",
+ "deleteError": "統合プロバイダーの削除に失敗しました",
+ "deleteConfirmTitle": "統合プロバイダーを削除",
+ "deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?各アプリで生成されたプロバイダー設定も削除されます。",
+ "syncConfirmTitle": "統合プロバイダーを同期",
+ "syncConfirmDescription": "「{{name}}」を同期すると、Claude、Codex、Gemini の関連プロバイダー設定が上書きされます。続行しますか?",
+ "syncConfirm": "同期",
+ "saveAndSync": "保存して同期",
+ "savedAndSynced": "すべてのアプリに保存・同期されました",
+ "saveAndSyncError": "保存と同期に失敗しました",
+ "configJsonPreview": "設定 JSON プレビュー",
+ "configJsonPreviewHint": "以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)"
}
}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 2b7d7cf5..73bf5855 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -66,6 +66,8 @@
"exitEditMode": "退出编辑模式"
},
"provider": {
+ "tabProvider": "供应商",
+ "tabUniversal": "统一供应商",
"noProviders": "还没有添加任何供应商",
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
"currentlyUsing": "当前使用",
@@ -340,6 +342,10 @@
"visitWebsite": "访问 {{url}}",
"anthropicModel": "主模型",
"anthropicSmallFastModel": "快速模型",
+ "anthropicReasoningModel": "推理模型 (Thinking)",
+ "reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
+ "openrouterCompatMode": "OpenRouter 兼容模式",
+ "openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
"anthropicDefaultOpusModel": "Opus 默认模型",
@@ -981,12 +987,85 @@
}
},
"settings": {
+ "title": "代理服务设置",
+ "description": "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
+ "alert": {
+ "autoApply": "保存后将自动同步到正在运行的代理服务,无需手动重启。"
+ },
+ "basic": {
+ "title": "基础设置",
+ "description": "配置代理服务监听的地址与端口。"
+ },
+ "advanced": {
+ "title": "高级参数",
+ "description": "控制请求的稳定性和日志记录。"
+ },
+ "timeout": {
+ "title": "超时设置",
+ "description": "配置流式和非流式请求的超时时间。"
+ },
+ "fields": {
+ "listenAddress": {
+ "label": "监听地址",
+ "placeholder": "127.0.0.1",
+ "description": "代理服务器监听的 IP 地址(推荐 127.0.0.1)"
+ },
+ "listenPort": {
+ "label": "监听端口",
+ "placeholder": "5000",
+ "description": "代理服务器监听的端口号(1024 ~ 65535)"
+ },
+ "maxRetries": {
+ "label": "最大重试次数",
+ "placeholder": "3",
+ "description": "请求失败时的重试次数(0 ~ 10)"
+ },
+ "requestTimeout": {
+ "label": "请求超时(秒)",
+ "placeholder": "0(不限)或 300",
+ "description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)"
+ },
+ "enableLogging": {
+ "label": "启用日志记录",
+ "description": "记录所有代理请求,便于排查问题"
+ },
+ "streamingFirstByteTimeout": {
+ "label": "流式首字超时(秒)",
+ "description": "等待首个数据块的最大时间"
+ },
+ "streamingIdleTimeout": {
+ "label": "流式静默超时(秒)",
+ "description": "数据块之间的最大间隔"
+ },
+ "nonStreamingTimeout": {
+ "label": "非流式超时(秒)",
+ "description": "非流式请求的总超时时间"
+ }
+ },
+ "validation": {
+ "addressInvalid": "请输入有效的IP地址",
+ "portMin": "端口必须大于1024",
+ "portMax": "端口必须小于65535",
+ "retryMin": "重试次数不能为负",
+ "retryMax": "重试次数不能超过10",
+ "timeoutNonNegative": "超时时间不能为负数",
+ "timeoutMax": "超时时间最多600秒",
+ "timeoutRange": "请输入 0 或 10-600 之间的数值",
+ "streamingTimeoutMin": "超时时间至少5秒",
+ "streamingTimeoutMax": "超时时间最多300秒"
+ },
+ "actions": {
+ "save": "保存配置"
+ },
"toast": {
"saved": "代理配置已保存",
"saveFailed": "保存失败: {{error}}"
}
},
"switchFailed": "切换失败: {{error}}",
+ "failover": {
+ "proxyRequired": "需要先启动代理服务才能配置故障转移"
+ },
"failoverQueue": {
"title": "故障转移队列",
"description": "管理各应用的供应商故障转移顺序",
@@ -1013,7 +1092,7 @@
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
"timeout": "恢复等待时间(秒)",
"timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
- "circuitBreakerSettings": "熔断器高级设置",
+ "circuitBreakerSettings": "熔断器设置",
"successThreshold": "恢复成功阈值",
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
"errorRate": "错误率阈值 (%)",
@@ -1042,5 +1121,65 @@
"timeout": "超时时间(秒)",
"maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)"
+ },
+ "proxyConfig": {
+ "proxyEnabled": "代理总开关",
+ "appTakeover": "代理启用",
+ "perAppConfig": "应用配置",
+ "circuitBreaker": "熔断器配置",
+ "circuitBreakerSettings": "熔断器设置",
+ "failureThreshold": "失败阈值",
+ "successThreshold": "恢复阈值",
+ "recoveryTimeout": "恢复等待时间",
+ "errorRateThreshold": "错误率阈值",
+ "minRequests": "最小请求数",
+ "timeoutConfig": "超时配置",
+ "streamingFirstByte": "流式首字节超时",
+ "streamingIdle": "流式静默超时",
+ "nonStreaming": "非流式超时"
+ },
+ "universalProvider": {
+ "title": "统一供应商",
+ "description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
+ "add": "添加统一供应商",
+ "edit": "编辑统一供应商",
+ "empty": "还没有统一供应商",
+ "emptyHint": "点击下方「添加统一供应商」按钮创建一个",
+ "selectPreset": "选择预设类型",
+ "name": "名称",
+ "namePlaceholder": "例如:我的 NewAPI",
+ "baseUrl": "API 地址",
+ "apiKey": "API Key",
+ "websiteUrl": "官网地址",
+ "websiteUrlPlaceholder": "https://example.com(可选,用于在列表中显示)",
+ "notes": "备注",
+ "notesPlaceholder": "可选:添加备注信息",
+ "enabledApps": "启用的应用",
+ "modelConfig": "模型配置",
+ "model": "模型",
+ "sync": "同步到应用",
+ "synced": "已同步到所有应用",
+ "syncError": "同步失败",
+ "noAppsEnabled": "未启用任何应用",
+ "added": "统一供应商已添加",
+ "addedAndSynced": "统一供应商已添加并同步",
+ "updated": "统一供应商已更新",
+ "deleted": "统一供应商已删除",
+ "addSuccess": "统一供应商添加成功",
+ "addFailed": "统一供应商添加失败",
+ "manage": "管理",
+ "loadError": "加载统一供应商失败",
+ "saveError": "保存统一供应商失败",
+ "deleteError": "删除统一供应商失败",
+ "deleteConfirmTitle": "删除统一供应商",
+ "deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?这将同时删除它在各应用中生成的供应商配置。",
+ "syncConfirmTitle": "同步统一供应商",
+ "syncConfirmDescription": "同步 \"{{name}}\" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?",
+ "syncConfirm": "同步",
+ "saveAndSync": "保存并同步",
+ "savedAndSynced": "已保存并同步到所有应用",
+ "saveAndSyncError": "保存并同步失败",
+ "configJsonPreview": "配置 JSON 预览",
+ "configJsonPreviewHint": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)"
}
}
diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts
index 507a41a7..4ae7755b 100644
--- a/src/icons/extracted/index.ts
+++ b/src/icons/extracted/index.ts
@@ -30,6 +30,7 @@ export const icons: Record = {
midjourney: `Midjourney `,
minimax: `Minimax `,
mistral: `Mistral `,
+ newapi: `NewAPI `,
notion: `Notion `,
ollama: `Ollama `,
openai: `OpenAI `,
@@ -42,6 +43,7 @@ export const icons: Record = {
vercel: `Vercel `,
wenxin: `Wenxin `,
xai: `Grok `,
+ xiaomimimo: `Logo `,
yi: `Yi `,
zeroone: `01.AI `,
zhipu: `Zhipu `,
diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts
index c0a9de38..61390476 100644
--- a/src/icons/extracted/metadata.ts
+++ b/src/icons/extracted/metadata.ts
@@ -198,6 +198,13 @@ export const iconMetadata: Record = {
keywords: ["mistral"],
defaultColor: "#FF7000",
},
+ newapi: {
+ name: "newapi",
+ displayName: "newapi",
+ category: "other",
+ keywords: [],
+ defaultColor: "currentColor",
+ },
notion: {
name: "notion",
displayName: "notion",
@@ -331,6 +338,13 @@ export const iconMetadata: Record = {
keywords: ["aihubmix", "hub", "mix", "aggregator"],
defaultColor: "#006FFB",
},
+ xiaomimimo: {
+ name: "xiaomimimo",
+ displayName: "Xiaomi MiMo",
+ category: "ai-provider",
+ keywords: ["xiaomimimo", "xiaomi", "mimo"],
+ defaultColor: "#000000",
+ },
};
export function getIconMetadata(name: string): IconMetadata | undefined {
diff --git a/src/icons/extracted/newapi.svg b/src/icons/extracted/newapi.svg
new file mode 100644
index 00000000..a9e1261b
--- /dev/null
+++ b/src/icons/extracted/newapi.svg
@@ -0,0 +1 @@
+NewAPI
\ No newline at end of file
diff --git a/src/icons/extracted/xiaomimimo.svg b/src/icons/extracted/xiaomimimo.svg
new file mode 100644
index 00000000..8467fa9f
--- /dev/null
+++ b/src/icons/extracted/xiaomimimo.svg
@@ -0,0 +1 @@
+Logo
\ No newline at end of file
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 9053449e..71a91517 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -1,10 +1,11 @@
export type { AppId } from "./types";
-export { providersApi } from "./providers";
+export { providersApi, universalProvidersApi } from "./providers";
export { settingsApi } from "./settings";
export { mcpApi } from "./mcp";
export { promptsApi } from "./prompts";
export { usageApi } from "./usage";
export { vscodeApi } from "./vscode";
+export { proxyApi } from "./proxy";
export * as configApi from "./config";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts
index 26609d81..eb3dc9da 100644
--- a/src/lib/api/providers.ts
+++ b/src/lib/api/providers.ts
@@ -1,6 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import type { Provider } from "@/types";
+import type {
+ Provider,
+ UniversalProvider,
+ UniversalProvidersMap,
+} from "@/types";
import type { AppId } from "./types";
export interface ProviderSortUpdate {
@@ -71,3 +75,44 @@ export const providersApi = {
return await invoke("open_provider_terminal", { providerId, app: appId });
},
};
+
+// ============================================================================
+// 统一供应商(Universal Provider)API
+// ============================================================================
+
+export const universalProvidersApi = {
+ /**
+ * 获取所有统一供应商
+ */
+ async getAll(): Promise {
+ return await invoke("get_universal_providers");
+ },
+
+ /**
+ * 获取单个统一供应商
+ */
+ async get(id: string): Promise {
+ return await invoke("get_universal_provider", { id });
+ },
+
+ /**
+ * 添加或更新统一供应商
+ */
+ async upsert(provider: UniversalProvider): Promise {
+ return await invoke("upsert_universal_provider", { provider });
+ },
+
+ /**
+ * 删除统一供应商
+ */
+ async delete(id: string): Promise {
+ return await invoke("delete_universal_provider", { id });
+ },
+
+ /**
+ * 手动同步统一供应商到各应用
+ */
+ async sync(id: string): Promise {
+ return await invoke("sync_universal_provider", { id });
+ },
+};
diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts
new file mode 100644
index 00000000..6009fe74
--- /dev/null
+++ b/src/lib/api/proxy.ts
@@ -0,0 +1,95 @@
+import { invoke } from "@tauri-apps/api/core";
+import type {
+ ProxyConfig,
+ ProxyStatus,
+ ProxyServerInfo,
+ ProxyTakeoverStatus,
+ GlobalProxyConfig,
+ AppProxyConfig,
+} from "@/types/proxy";
+
+export const proxyApi = {
+ // ========== 代理服务器控制 API ==========
+
+ // 启动代理服务器
+ async startProxyServer(): Promise {
+ return invoke("start_proxy_server");
+ },
+
+ // 停止代理服务器并恢复配置
+ async stopProxyWithRestore(): Promise {
+ return invoke("stop_proxy_with_restore");
+ },
+
+ // 获取代理服务器状态
+ async getProxyStatus(): Promise {
+ return invoke("get_proxy_status");
+ },
+
+ // 检查代理服务器是否正在运行
+ async isProxyRunning(): Promise {
+ return invoke("is_proxy_running");
+ },
+
+ // 检查是否处于接管模式
+ async isLiveTakeoverActive(): Promise {
+ return invoke("is_live_takeover_active");
+ },
+
+ // 代理模式下切换供应商
+ async switchProxyProvider(
+ appType: string,
+ providerId: string,
+ ): Promise {
+ return invoke("switch_proxy_provider", { appType, providerId });
+ },
+
+ // ========== 接管状态 API ==========
+
+ // 获取各应用接管状态
+ async getProxyTakeoverStatus(): Promise {
+ return invoke("get_proxy_takeover_status");
+ },
+
+ // 为指定应用开启/关闭接管
+ async setProxyTakeoverForApp(
+ appType: string,
+ enabled: boolean,
+ ): Promise {
+ return invoke("set_proxy_takeover_for_app", { appType, enabled });
+ },
+
+ // ========== Legacy 代理配置 API (兼容) ==========
+
+ // 获取代理配置(旧版 v2 兼容接口)
+ async getProxyConfig(): Promise {
+ return invoke("get_proxy_config");
+ },
+
+ // 更新代理配置(旧版 v2 兼容接口)
+ async updateProxyConfig(config: ProxyConfig): Promise {
+ return invoke("update_proxy_config", { config });
+ },
+
+ // ========== v3+ 全局/应用级配置 API ==========
+
+ // 获取全局代理配置
+ async getGlobalProxyConfig(): Promise {
+ return invoke("get_global_proxy_config");
+ },
+
+ // 更新全局代理配置
+ async updateGlobalProxyConfig(config: GlobalProxyConfig): Promise {
+ return invoke("update_global_proxy_config", { config });
+ },
+
+ // 获取指定应用的代理配置
+ async getProxyConfigForApp(appType: string): Promise {
+ return invoke("get_proxy_config_for_app", { appType });
+ },
+
+ // 更新指定应用的代理配置
+ async updateProxyConfigForApp(config: AppProxyConfig): Promise {
+ return invoke("update_proxy_config_for_app", { config });
+ },
+};
diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts
index a3961bf6..223ec032 100644
--- a/src/lib/query/index.ts
+++ b/src/lib/query/index.ts
@@ -1,3 +1,4 @@
export * from "./queryClient";
export * from "./queries";
export * from "./mutations";
+export * from "./proxy";
diff --git a/src/lib/query/proxy.ts b/src/lib/query/proxy.ts
new file mode 100644
index 00000000..38e16248
--- /dev/null
+++ b/src/lib/query/proxy.ts
@@ -0,0 +1,239 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { proxyApi } from "@/lib/api/proxy";
+import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
+import type { GlobalProxyConfig, AppProxyConfig } from "@/types/proxy";
+
+// ========== 代理服务器状态 Hooks ==========
+
+/**
+ * 获取代理服务器状态
+ */
+export function useProxyStatus() {
+ return useQuery({
+ queryKey: ["proxyStatus"],
+ queryFn: () => proxyApi.getProxyStatus(),
+ refetchInterval: 5000, // 每 5 秒刷新一次
+ });
+}
+
+/**
+ * 检查代理服务器是否运行
+ */
+export function useIsProxyRunning() {
+ return useQuery({
+ queryKey: ["proxyRunning"],
+ queryFn: () => proxyApi.isProxyRunning(),
+ refetchInterval: 2000,
+ });
+}
+
+/**
+ * 检查是否处于接管模式
+ */
+export function useIsLiveTakeoverActive() {
+ return useQuery({
+ queryKey: ["liveTakeoverActive"],
+ queryFn: () => proxyApi.isLiveTakeoverActive(),
+ refetchInterval: 2000,
+ });
+}
+
+/**
+ * 获取各应用接管状态
+ */
+export function useProxyTakeoverStatus() {
+ return useQuery({
+ queryKey: ["proxyTakeoverStatus"],
+ queryFn: () => proxyApi.getProxyTakeoverStatus(),
+ refetchInterval: 2000,
+ });
+}
+
+// ========== 代理服务器控制 Hooks ==========
+
+/**
+ * 启动代理服务器
+ */
+export function useStartProxyServer() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => proxyApi.startProxyServer(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyRunning"] });
+ queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
+ },
+ });
+}
+
+/**
+ * 停止代理服务器
+ */
+export function useStopProxyServer() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => proxyApi.stopProxyWithRestore(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyRunning"] });
+ queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
+ },
+ });
+}
+
+/**
+ * 设置应用接管状态
+ */
+export function useSetProxyTakeoverForApp() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
+ proxyApi.setProxyTakeoverForApp(appType, enabled),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
+ queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] });
+ },
+ });
+}
+
+/**
+ * 代理模式下切换供应商
+ */
+export function useSwitchProxyProvider() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: ({
+ appType,
+ providerId,
+ }: {
+ appType: string;
+ providerId: string;
+ }) => proxyApi.switchProxyProvider(appType, providerId),
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
+ queryClient.invalidateQueries({
+ queryKey: ["providers", variables.appType],
+ });
+ },
+ onError: (error: Error) => {
+ toast.error(t("proxy.switchFailed", { error: error.message }));
+ },
+ });
+}
+
+// ========== Legacy 代理配置 Hooks (兼容) ==========
+
+/**
+ * 获取代理配置(旧版)
+ */
+export function useProxyConfig() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ const { data: config, isLoading } = useQuery({
+ queryKey: ["proxyConfig"],
+ queryFn: () => proxyApi.getProxyConfig(),
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: proxyApi.updateProxyConfig,
+ onSuccess: () => {
+ toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
+ queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
+ },
+ onError: (error: Error) => {
+ toast.error(
+ t("proxy.settings.toast.saveFailed", { error: error.message }),
+ );
+ },
+ });
+
+ return {
+ config,
+ isLoading,
+ updateConfig: updateMutation.mutateAsync,
+ isUpdating: updateMutation.isPending,
+ };
+}
+
+// ========== v3+ 全局/应用级配置 Hooks ==========
+
+/**
+ * 获取全局代理配置
+ */
+export function useGlobalProxyConfig() {
+ return useQuery({
+ queryKey: ["globalProxyConfig"],
+ queryFn: () => proxyApi.getGlobalProxyConfig(),
+ });
+}
+
+/**
+ * 更新全局代理配置
+ */
+export function useUpdateGlobalProxyConfig() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (config: GlobalProxyConfig) =>
+ proxyApi.updateGlobalProxyConfig(config),
+ onSuccess: () => {
+ toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
+ queryClient.invalidateQueries({ queryKey: ["globalProxyConfig"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
+ queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
+ },
+ onError: (error: Error) => {
+ toast.error(
+ t("proxy.settings.toast.saveFailed", { error: error.message }),
+ );
+ },
+ });
+}
+
+/**
+ * 获取指定应用的代理配置
+ */
+export function useAppProxyConfig(appType: string) {
+ return useQuery({
+ queryKey: ["appProxyConfig", appType],
+ queryFn: () => proxyApi.getProxyConfigForApp(appType),
+ enabled: !!appType,
+ });
+}
+
+/**
+ * 更新指定应用的代理配置
+ */
+export function useUpdateAppProxyConfig() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (config: AppProxyConfig) =>
+ proxyApi.updateProxyConfigForApp(config),
+ onSuccess: (_, variables) => {
+ toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
+ queryClient.invalidateQueries({
+ queryKey: ["appProxyConfig", variables.appType],
+ });
+ queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
+ queryClient.invalidateQueries({ queryKey: ["circuitBreakerConfig"] });
+ },
+ onError: (error: Error) => {
+ toast.error(
+ t("proxy.settings.toast.saveFailed", { error: error.message }),
+ );
+ },
+ });
+}
diff --git a/src/types.ts b/src/types.ts
index d6e38d7a..2761319b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -186,3 +186,61 @@ export interface McpConfigResponse {
configPath: string;
servers: Record;
}
+
+// ============================================================================
+// 统一供应商(Universal Provider)- 跨应用共享配置
+// ============================================================================
+
+// 统一供应商的应用启用状态
+export interface UniversalProviderApps {
+ claude: boolean;
+ codex: boolean;
+ gemini: boolean;
+}
+
+// Claude 模型配置
+export interface ClaudeModelConfig {
+ model?: string;
+ haikuModel?: string;
+ sonnetModel?: string;
+ opusModel?: string;
+}
+
+// Codex 模型配置
+export interface CodexModelConfig {
+ model?: string;
+ reasoningEffort?: string;
+}
+
+// Gemini 模型配置
+export interface GeminiModelConfig {
+ model?: string;
+}
+
+// 各应用的模型配置
+export interface UniversalProviderModels {
+ claude?: ClaudeModelConfig;
+ codex?: CodexModelConfig;
+ gemini?: GeminiModelConfig;
+}
+
+// 统一供应商(跨应用共享配置)
+export interface UniversalProvider {
+ id: string;
+ name: string;
+ providerType: string; // "newapi" | "custom" 等
+ apps: UniversalProviderApps;
+ baseUrl: string;
+ apiKey: string;
+ models: UniversalProviderModels;
+ websiteUrl?: string;
+ notes?: string;
+ icon?: string;
+ iconColor?: string;
+ meta?: ProviderMeta;
+ createdAt?: number;
+ sortIndex?: number;
+}
+
+// 统一供应商映射(id -> UniversalProvider)
+export type UniversalProvidersMap = Record;
diff --git a/src/types/proxy.ts b/src/types/proxy.ts
index cc809dfe..4d11370a 100644
--- a/src/types/proxy.ts
+++ b/src/types/proxy.ts
@@ -5,6 +5,10 @@ export interface ProxyConfig {
request_timeout: number;
enable_logging: boolean;
live_takeover_active?: boolean;
+ // 超时配置
+ streaming_first_byte_timeout: number;
+ streaming_idle_timeout: number;
+ non_streaming_timeout: number;
}
export interface ProxyStatus {
@@ -105,3 +109,27 @@ export interface FailoverQueueItem {
providerName: string;
sortIndex?: number;
}
+
+// 全局代理配置(统一字段,三行镜像)
+export interface GlobalProxyConfig {
+ proxyEnabled: boolean;
+ listenAddress: string;
+ listenPort: number;
+ enableLogging: boolean;
+}
+
+// 应用级代理配置(每个 app 独立)
+export interface AppProxyConfig {
+ appType: string;
+ enabled: boolean;
+ autoFailoverEnabled: boolean;
+ maxRetries: number;
+ streamingFirstByteTimeout: number;
+ streamingIdleTimeout: number;
+ nonStreamingTimeout: number;
+ circuitFailureThreshold: number;
+ circuitSuccessThreshold: number;
+ circuitTimeoutSeconds: number;
+ circuitErrorRateThreshold: number;
+ circuitMinRequests: number;
+}
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
new file mode 100644
index 00000000..65bd50a0
--- /dev/null
+++ b/tailwind.config.cjs
@@ -0,0 +1,172 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ["./src/index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ darkMode: ["selector", "class"],
+ theme: {
+ extend: {
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ blue: {
+ 400: "#409CFF",
+ 500: "#0A84FF",
+ 600: "#0060DF",
+ },
+ gray: {
+ 50: "#fafafa",
+ 100: "#f4f4f5",
+ 200: "#e4e4e7",
+ 300: "#d4d4d8",
+ 400: "#a1a1aa",
+ 500: "#71717a",
+ 600: "#636366",
+ 700: "#48484A",
+ 800: "#3A3A3C",
+ 900: "#2C2C2E",
+ 950: "#1C1C1E",
+ },
+ green: {
+ 100: "#d1fae5",
+ 500: "#10b981",
+ },
+ red: {
+ 100: "#fee2e2",
+ 500: "#ef4444",
+ },
+ amber: {
+ 100: "#fef3c7",
+ 500: "#f59e0b",
+ },
+ },
+ boxShadow: {
+ sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
+ md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
+ lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
+ },
+ borderRadius: {
+ sm: "0.375rem",
+ md: "0.5rem",
+ lg: "0.75rem",
+ xl: "0.875rem",
+ },
+ fontFamily: {
+ // 使用与之前版本保持一致的系统字体栈
+ sans: [
+ "-apple-system",
+ "BlinkMacSystemFont",
+ '"Segoe UI"',
+ "Roboto",
+ '"Helvetica Neue"',
+ "Arial",
+ "sans-serif",
+ ],
+ mono: [
+ "ui-monospace",
+ "SFMono-Regular",
+ '"SF Mono"',
+ "Consolas",
+ '"Liberation Mono"',
+ "Menlo",
+ "monospace",
+ ],
+ },
+ animation: {
+ "fade-in": "fadeIn 0.5s ease-out",
+ "slide-up": "slideUp 0.5s ease-out",
+ "slide-down": "slideDown 0.3s ease-out",
+ "slide-in-right": "slideInRight 0.3s ease-out",
+ "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ keyframes: {
+ fadeIn: {
+ "0%": {
+ opacity: "0",
+ },
+ "100%": {
+ opacity: "1",
+ },
+ },
+ slideUp: {
+ "0%": {
+ transform: "translateY(20px)",
+ opacity: "0",
+ },
+ "100%": {
+ transform: "translateY(0)",
+ opacity: "1",
+ },
+ },
+ slideDown: {
+ "0%": {
+ transform: "translateY(-100%)",
+ opacity: "0",
+ },
+ "100%": {
+ transform: "translateY(0)",
+ opacity: "1",
+ },
+ },
+ slideInRight: {
+ "0%": {
+ transform: "translateX(100%)",
+ opacity: "0",
+ },
+ "100%": {
+ transform: "translateX(0)",
+ opacity: "1",
+ },
+ },
+ "accordion-down": {
+ from: {
+ height: "0",
+ },
+ to: {
+ height: "var(--radix-accordion-content-height)",
+ },
+ },
+ "accordion-up": {
+ from: {
+ height: "var(--radix-accordion-content-height)",
+ },
+ to: {
+ height: "0",
+ },
+ },
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/tailwind.config.js b/tailwind.config.js
deleted file mode 100644
index f433aba8..00000000
--- a/tailwind.config.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-export default {
- content: [
- "./src/index.html",
- "./src/**/*.{js,ts,jsx,tsx}",
- ],
- darkMode: ["selector", "class"],
- theme: {
- extend: {
- colors: {
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))'
- },
- popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))'
- },
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))'
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))'
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))'
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))'
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))'
- },
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- blue: {
- '400': '#409CFF',
- '500': '#0A84FF',
- '600': '#0060DF'
- },
- gray: {
- '50': '#fafafa',
- '100': '#f4f4f5',
- '200': '#e4e4e7',
- '300': '#d4d4d8',
- '400': '#a1a1aa',
- '500': '#71717a',
- '600': '#636366',
- '700': '#48484A',
- '800': '#3A3A3C',
- '900': '#2C2C2E',
- '950': '#1C1C1E'
- },
- green: {
- '100': '#d1fae5',
- '500': '#10b981'
- },
- red: {
- '100': '#fee2e2',
- '500': '#ef4444'
- },
- amber: {
- '100': '#fef3c7',
- '500': '#f59e0b'
- }
- },
- boxShadow: {
- sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
- md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
- lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)'
- },
- borderRadius: {
- sm: '0.375rem',
- md: '0.5rem',
- lg: '0.75rem',
- xl: '0.875rem'
- },
- fontFamily: {
- // 使用与之前版本保持一致的系统字体栈
- sans: [
- '-apple-system',
- 'BlinkMacSystemFont',
- '"Segoe UI"',
- 'Roboto',
- '"Helvetica Neue"',
- 'Arial',
- 'sans-serif',
- ],
- mono: [
- 'ui-monospace',
- 'SFMono-Regular',
- '"SF Mono"',
- 'Consolas',
- '"Liberation Mono"',
- 'Menlo',
- 'monospace',
- ],
- },
- animation: {
- 'fade-in': 'fadeIn 0.5s ease-out',
- 'slide-up': 'slideUp 0.5s ease-out',
- 'slide-down': 'slideDown 0.3s ease-out',
- 'slide-in-right': 'slideInRight 0.3s ease-out',
- 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
- 'accordion-down': 'accordion-down 0.2s ease-out',
- 'accordion-up': 'accordion-up 0.2s ease-out'
- },
- keyframes: {
- fadeIn: {
- '0%': {
- opacity: '0'
- },
- '100%': {
- opacity: '1'
- }
- },
- slideUp: {
- '0%': {
- transform: 'translateY(20px)',
- opacity: '0'
- },
- '100%': {
- transform: 'translateY(0)',
- opacity: '1'
- }
- },
- slideDown: {
- '0%': {
- transform: 'translateY(-100%)',
- opacity: '0'
- },
- '100%': {
- transform: 'translateY(0)',
- opacity: '1'
- }
- },
- slideInRight: {
- '0%': {
- transform: 'translateX(100%)',
- opacity: '0'
- },
- '100%': {
- transform: 'translateX(0)',
- opacity: '1'
- }
- },
- 'accordion-down': {
- from: {
- height: '0'
- },
- to: {
- height: 'var(--radix-accordion-content-height)'
- }
- },
- 'accordion-up': {
- from: {
- height: 'var(--radix-accordion-content-height)'
- },
- to: {
- height: '0'
- }
- }
- }
- }
- },
- plugins: [],
-}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index 18f4fe77..15123cb9 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -9,7 +9,12 @@
"esModuleInterop": true,
"target": "ES2020",
"strict": true,
- "types": ["node"]
+ "types": [
+ "node"
+ ]
},
- "include": ["vite.config.mts", "vitest.config.ts"]
-}
+ "include": [
+ "vite.config.ts",
+ "vitest.config.ts"
+ ]
+}
\ No newline at end of file
diff --git a/vite.config.mts b/vite.config.ts
similarity index 61%
rename from vite.config.mts
rename to vite.config.ts
index f64dbfd9..ea808127 100644
--- a/vite.config.mts
+++ b/vite.config.ts
@@ -1,10 +1,17 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+import { codeInspectorPlugin } from "code-inspector-plugin";
-export default defineConfig({
+export default defineConfig(({ command }) => ({
root: "src",
- plugins: [react()],
+ plugins: [
+ command === "serve" &&
+ codeInspectorPlugin({
+ bundler: "vite",
+ }),
+ react(),
+ ].filter(Boolean),
base: "./",
build: {
outDir: "../dist",
@@ -21,4 +28,4 @@ export default defineConfig({
},
clearScreen: false,
envPrefix: ["VITE_", "TAURI_"],
-});
+}));