Merge upstream/main into main

Resolved conflicts in src-tauri/src/lib.rs:
- Kept both: open_provider_terminal (my feature) and universal provider commands (upstream)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
w0x7ce
2025-12-29 09:14:42 +08:00
84 changed files with 6164 additions and 1498 deletions
+47 -39
View File
@@ -1,6 +1,6 @@
import React from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -33,49 +33,57 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Header */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
{/* Header */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button
type="button"
variant="outline"
size="icon"
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
)}
</motion.div>
)}
</motion.div>,
</AnimatePresence>,
document.body,
);
};
+2 -2
View File
@@ -191,11 +191,11 @@ export function EnvWarningBanner({
<div className="flex-1 min-w-0">
<label
htmlFor={key}
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
className="block text-sm font-medium text-foreground cursor-pointer"
>
{conflict.varName}
</label>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
<p className="text-xs text-muted-foreground mt-1 break-all">
{t("env.field.value")}: {conflict.varValue}
</p>
<p className="text-xs text-muted-foreground mt-1">
+15 -15
View File
@@ -239,7 +239,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Hint */}
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
<p className="text-sm text-gray-500 dark:text-gray-400">
<p className="text-sm text-muted-foreground">
{t("mcp.wizard.hint")}
</p>
</div>
@@ -248,7 +248,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<div className="space-y-4 min-h-[400px]">
{/* Type */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-2 block text-sm font-medium text-foreground">
{t("mcp.wizard.type")} <span className="text-red-500">*</span>
</label>
<div className="flex gap-4">
@@ -262,7 +262,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
}
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
<span className="text-sm text-foreground">
{t("mcp.wizard.typeStdio")}
</span>
</label>
@@ -276,7 +276,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
}
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
<span className="text-sm text-foreground">
{t("mcp.wizard.typeHttp")}
</span>
</label>
@@ -290,7 +290,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
}
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
<span className="text-sm text-foreground">
{t("mcp.wizard.typeSse")}
</span>
</label>
@@ -299,7 +299,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{/* Title */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
<Input
@@ -317,7 +317,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<>
{/* Command */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.command")}{" "}
<span className="text-red-500">*</span>
</label>
@@ -333,7 +333,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{/* Args */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.args")}
</label>
<textarea
@@ -341,13 +341,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardArgs(e.target.value)}
placeholder={t("mcp.wizard.argsPlaceholder")}
rows={3}
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
{/* Env */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.env")}
</label>
<textarea
@@ -355,7 +355,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardEnv(e.target.value)}
placeholder={t("mcp.wizard.envPlaceholder")}
rows={3}
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -366,7 +366,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<>
{/* URL */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.url")}{" "}
<span className="text-red-500">*</span>
</label>
@@ -382,7 +382,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{/* Headers */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.headers")}
</label>
<textarea
@@ -390,7 +390,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardHeaders(e.target.value)}
placeholder={t("mcp.wizard.headersPlaceholder")}
rows={3}
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -404,7 +404,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
wizardUrl ||
wizardHeaders) && (
<div className="space-y-2 border-t border-border-default pt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
<h3 className="text-sm font-medium text-foreground">
{t("mcp.wizard.preview")}
</h3>
<pre className="overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300">
+121 -35
View File
@@ -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<UniversalProviderPreset | null>(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 = (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form="provider-form"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4 mr-2" />
{t("common.add")}
</Button>
</>
);
// 动态 footer:根据当前 Tab 显示不同按钮
const footer =
activeTab === "app-specific" ? (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form="provider-form"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4 mr-2" />
{t("common.add")}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
onClick={() => setUniversalFormOpen(true)}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4 mr-2" />
{t("universalProvider.add")}
</Button>
</>
);
return (
<FullScreenPanel
isOpen={open}
title={submitLabel}
title={t("provider.addNewProvider")}
onClose={() => onOpenChange(false)}
footer={footer}
>
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
</TabsList>
<TabsContent value="app-specific" className="mt-0">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</TabsContent>
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
</Tabs>
{/* Universal Provider Form Modal */}
<UniversalProviderFormModal
isOpen={universalFormOpen}
onClose={handleUniversalFormClose}
onSave={handleUniversalProviderSave}
initialPreset={selectedUniversalPreset}
/>
</FullScreenPanel>
);
@@ -31,15 +31,12 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
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 (
<div className="space-y-2">
<label
htmlFor={id}
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
<label htmlFor={id} className="block text-sm font-medium text-foreground">
{label} {required && "*"}
</label>
<div className="relative">
@@ -58,7 +55,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
<button
type="button"
onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -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 && (
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("providerForm.openrouterCompatMode", {
defaultValue: "OpenRouter 兼容模式",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.openrouterCompatModeHint", {
defaultValue:
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
})}
</p>
</div>
<Switch
checked={openRouterCompatEnabled}
onCheckedChange={onOpenRouterCompatChange}
/>
</div>
)}
{/* 模型选择器 */}
{shouldShowModelSelector && (
<div className="space-y-3">
@@ -185,6 +219,27 @@ export function ClaudeFormFields({
/>
</div>
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel", {
defaultValue: "推理模型 (Thinking)",
})}
</FormLabel>
<Input
id="reasoningModel"
type="text"
value={reasoningModel}
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
{/* 默认 Haiku */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultHaikuModel">
@@ -47,7 +47,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
<div className="space-y-2">
<label
htmlFor="codexAuth"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.authJson")}
</label>
@@ -67,7 +67,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
)}
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{t("codexConfig.authJsonHint")}
</p>
)}
@@ -120,12 +120,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
<div className="flex items-center justify-between">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.configToml")}
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
@@ -167,7 +167,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
)}
{!configError && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{t("codexConfig.configTomlHint")}
</p>
)}
@@ -98,7 +98,7 @@ export function CodexFormFields({
<div className="space-y-2">
<label
htmlFor="codexModelName"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
</label>
@@ -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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{t("codexConfig.modelNameHint", {
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
})}
@@ -47,7 +47,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
<div className="space-y-2">
<label
htmlFor="geminiEnv"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
@@ -69,7 +69,7 @@ GEMINI_MODEL=gemini-3-pro-preview`}
)}
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{t("geminiConfig.envFileHint", {
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
})}
@@ -124,14 +124,14 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
<div className="flex items-center justify-between">
<label
htmlFor="geminiConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.configJson", {
defaultValue: "配置文件 (config.json)",
})}
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
@@ -180,7 +180,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
)}
{!configError && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{t("geminiConfig.configJsonHint", {
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
})}
@@ -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}
/>
)}
@@ -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<string, string>;
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({
});
})}
</div>
{/* 统一供应商预设(新的一行) */}
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => 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",
})}
>
<ProviderIcon icon={preset.icon} name={preset.name} size={14} />
{preset.name}
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
</button>
))}
{/* 管理统一供应商按钮 */}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
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"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4" />
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</button>
)}
</div>
</>
)}
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
</div>
);
@@ -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,
@@ -40,7 +40,7 @@ export function EndpointField({
<button
type="button"
onClick={onManageClick}
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Zap className="h-3.5 w-3.5" />
{manageButtonLabel || defaultManageLabel}
+208 -132
View File
@@ -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 (
<div className="border-0 rounded-none shadow-none bg-transparent">
{/* 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).
*/}
<div className="space-y-4">
{error && (
<Alert variant="destructive">
@@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="failureThreshold">
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
<Label htmlFor={`maxRetries-${appType}`}>
{t("proxy.autoFailover.maxRetries", "最大重试次数")}
</Label>
<Input
id="failureThreshold"
id={`maxRetries-${appType}`}
type="number"
min="1"
max="20"
value={formData.failureThreshold}
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
maxRetries: parseInt(e.target.value) || 3,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.maxRetriesHint",
"请求失败时的重试次数(0-10",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`failureThreshold-${appType}`}>
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
</Label>
<Input
id={`failureThreshold-${appType}`}
type="number"
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) =>
setFormData({
...formData,
circuitFailureThreshold: parseInt(e.target.value) || 5,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({
)}
</p>
</div>
</div>
</div>
{/* 超时配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.timeoutSettings", "超时配置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="timeoutSeconds">
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
<Label htmlFor={`streamingFirstByte-${appType}`}>
{t(
"proxy.autoFailover.streamingFirstByte",
"流式首字节超时(秒)",
)}
</Label>
<Input
id="timeoutSeconds"
id={`streamingFirstByte-${appType}`}
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
"proxy.autoFailover.streamingFirstByteHint",
"等待首个数据块的最大时间",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`streamingIdle-${appType}`}>
{t("proxy.autoFailover.streamingIdle", "流式静默超时(秒)")}
</Label>
<Input
id={`streamingIdle-${appType}`}
type="number"
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingIdleHint",
"数据块之间的最大间隔",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`nonStreaming-${appType}`}>
{t("proxy.autoFailover.nonStreaming", "非流式超时(秒)")}
</Label>
<Input
id={`nonStreaming-${appType}`}
type="number"
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: parseInt(e.target.value) || 300,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.nonStreamingHint",
"非流式请求的总超时时间",
)}
</p>
</div>
</div>
</div>
{/* 熔断器高级配置 */}
{/* 熔断器配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="successThreshold">
<Label htmlFor={`successThreshold-${appType}`}>
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
</Label>
<Input
id="successThreshold"
id={`successThreshold-${appType}`}
type="number"
min="1"
max="10"
value={formData.successThreshold}
value={formData.circuitSuccessThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
circuitSuccessThreshold: parseInt(e.target.value) || 2,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -201,23 +310,50 @@ export function AutoFailoverConfigPanel({
</div>
<div className="space-y-2">
<Label htmlFor="errorRateThreshold">
<Label htmlFor={`timeoutSeconds-${appType}`}>
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id={`timeoutSeconds-${appType}`}
type="number"
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`errorRateThreshold-${appType}`}>
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
</Label>
<Input
id="errorRateThreshold"
id={`errorRateThreshold-${appType}`}
type="number"
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
circuitErrorRateThreshold:
(parseInt(e.target.value) || 50) / 100,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({
</div>
<div className="space-y-2">
<Label htmlFor="minRequests">
<Label htmlFor={`minRequests-${appType}`}>
{t("proxy.autoFailover.minRequests", "最小请求数")}
</Label>
<Input
id="minRequests"
id={`minRequests-${appType}`}
type="number"
min="5"
max="100"
value={formData.minRequests}
value={formData.circuitMinRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
circuitMinRequests: parseInt(e.target.value) || 10,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -257,17 +393,10 @@ export function AutoFailoverConfigPanel({
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !enabled}
>
<Button variant="outline" onClick={handleReset} disabled={isDisabled}>
{t("common.reset", "重置")}
</Button>
<Button
onClick={handleSave}
disabled={updateConfig.isPending || !enabled}
>
<Button onClick={handleSave} disabled={isDisabled}>
{updateConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -281,59 +410,6 @@ export function AutoFailoverConfigPanel({
)}
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("proxy.autoFailover.explanationTitle", "工作原理")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
</strong>
{t(
"proxy.autoFailover.failureThresholdExplain",
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
</strong>
{t(
"proxy.autoFailover.timeoutExplain",
"熔断器打开后,等待此时间后尝试半开状态",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
</strong>
{t(
"proxy.autoFailover.successThresholdExplain",
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
</strong>
{t(
"proxy.autoFailover.errorRateExplain",
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
)}
</li>
</ul>
</div>
</div>
</div>
);
+261 -46
View File
@@ -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() {
<div className="space-y-6">
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">
{t("proxy.panel.serviceAddress", {
defaultValue: "服务地址",
})}
</p>
<Button
size="sm"
variant="ghost"
onClick={() => setShowSettings(true)}
className="h-7 gap-1.5 text-xs"
>
<Settings className="h-3.5 w-3.5" />
{t("common.settings")}
</Button>
</div>
<p className="text-xs text-muted-foreground mb-2">
{t("proxy.panel.serviceAddress", {
defaultValue: "服务地址",
})}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port}
@@ -87,6 +167,11 @@ export function ProxyPanel() {
{t("common.copy")}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
{t("proxy.settings.restartRequired", {
defaultValue: "修改监听地址/端口需要先停止代理服务",
})}
</p>
</div>
<div className="pt-3 border-t border-border space-y-2">
@@ -130,6 +215,63 @@ export function ProxyPanel() {
)}
</div>
{/* 应用接管开关 */}
<div className="pt-3 border-t border-border space-y-3">
<p className="text-xs text-muted-foreground">
{t("proxyConfig.appTakeover", {
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
</div>
{/* 日志记录开关 */}
<div className="pt-3 border-t border-border">
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</Label>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</p>
</div>
<Switch
checked={globalConfig?.enableLogging ?? true}
onCheckedChange={handleLoggingChange}
disabled={updateGlobalConfig.isPending}
/>
</div>
</div>
{/* 供应商队列 - 按应用类型分组展示 */}
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
@@ -217,36 +359,109 @@ export function ProxyPanel() {
</div>
</div>
) : (
<div className="text-center py-10 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
<div className="space-y-6">
{/* 空白区域避免冲突 */}
<div className="h-4"></div>
{/* 基础设置 - 监听地址/端口 */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="listen-address">
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</Label>
<Input
id="listen-address"
value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)}
placeholder="127.0.0.1"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="listen-port">
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</Label>
<Input
id="listen-port"
type="number"
value={listenPort}
onChange={(e) =>
setListenPort(parseInt(e.target.value) || 5000)
}
placeholder="5000"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", {
defaultValue: "代理服务器监听的端口号(1024 ~ 65535",
})}
</p>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSaveBasicConfig}
disabled={updateGlobalConfig.isPending}
>
{updateGlobalConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", { defaultValue: "保存中..." })}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", { defaultValue: "保存" })}
</>
)}
</Button>
</div>
</div>
{/* 代理服务已停止提示 */}
<div className="text-center py-6 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground mb-4">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
<Button
size="sm"
variant="outline"
onClick={() => setShowSettings(true)}
className="gap-1.5"
>
<Settings className="h-4 w-4" />
{t("proxy.panel.openSettings", {
defaultValue: "配置代理服务",
})}
</Button>
</div>
)}
</section>
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
</>
);
}
@@ -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<ProxyConfigForm>({
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 (
<FullScreenPanel
isOpen={open}
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
onClose={closePanel}
footer={
<>
<Button
type="button"
variant="outline"
onClick={closePanel}
disabled={isUpdating}
>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="submit"
form={formId}
disabled={isUpdating || isLoading}
>
{isUpdating
? t("common.saving", { defaultValue: "保存中..." })
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
</Button>
</>
}
>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("proxy.settings.description", {
defaultValue:
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
})}
</p>
<Alert className="border-emerald-500/40 bg-emerald-500/10">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{t("proxy.settings.alert.autoApply", {
defaultValue:
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
})}
</AlertDescription>
</Alert>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="listen_address"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{ defaultValue: "127.0.0.1" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listen_port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{ defaultValue: "5000" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenPort.description", {
defaultValue:
"代理服务器监听的端口号(1024 ~ 65535",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.advanced.title", {
defaultValue: "高级参数",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.advanced.description", {
defaultValue: "控制请求的稳定性和日志记录。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.maxRetries.label", {
defaultValue: "最大重试次数",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.maxRetries.placeholder",
{ defaultValue: "3" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.maxRetries.description", {
defaultValue: "请求失败时的重试次数(0 ~ 10)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="request_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.requestTimeout.label", {
defaultValue: "请求超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.requestTimeout.placeholder",
{ defaultValue: "0(不限)或 300" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.requestTimeout.description", {
defaultValue:
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="enable_logging"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</section>
</form>
</Form>
</div>
</FullScreenPanel>
);
}
-1
View File
@@ -3,4 +3,3 @@
*/
export { ProxyPanel } from "./ProxyPanel";
export { ProxySettingsDialog } from "./ProxySettingsDialog";
+71 -29
View File
@@ -384,47 +384,89 @@ export function SettingsPage({
</div>
)}
{/* 故障转移队列管理 - 每个应用独立 */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent value="claude" className="mt-4">
{/* 故障转移设置 - 按应用分组 */}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent
value="claude"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="claude"
disabled={!isRunning}
/>
</TabsContent>
<TabsContent value="codex" className="mt-4">
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="codex"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</TabsContent>
<TabsContent value="gemini" className="mt-4">
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="gemini"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</TabsContent>
</Tabs>
</div>
{/* 熔断器配置 - 全局共享 */}
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel />
</div>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
</Tabs>
</div>
</AccordionContent>
</AccordionItem>
+4 -4
View File
@@ -231,10 +231,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
<p className="text-lg font-medium text-foreground">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
<Button
@@ -306,10 +306,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
{/* 技能列表或无结果提示 */}
{filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
<p className="text-lg font-medium text-foreground">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
</div>
@@ -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 (
<div className="group relative rounded-xl border border-border/50 bg-card p-4 transition-all hover:border-border hover:shadow-md">
{/* 头部:图标和名称 */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent">
<ProviderIcon icon={provider.icon} name={provider.name} size={24} />
</div>
<div>
<h3 className="font-semibold text-foreground">{provider.name}</h3>
<p className="text-xs text-muted-foreground">
{provider.providerType}
</p>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onSync(provider.id)}
title={t("universalProvider.sync", { defaultValue: "同步到应用" })}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(provider)}
title={t("common.edit", { defaultValue: "编辑" })}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDelete(provider.id)}
title={t("common.delete", { defaultValue: "删除" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 配置信息 */}
<div className="mt-4 space-y-2">
{/* Base URL */}
<div className="flex items-center gap-2 text-sm">
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate text-muted-foreground">
{provider.baseUrl || "-"}
</span>
</div>
{/* 启用的应用 */}
<div className="flex flex-wrap gap-1.5">
{enabledApps.map((app) => (
<span
key={app}
className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{app}
</span>
))}
{enabledApps.length === 0 && (
<span className="text-xs text-muted-foreground">
{t("universalProvider.noAppsEnabled", {
defaultValue: "未启用任何应用",
})}
</span>
)}
</div>
</div>
{/* 备注 */}
{provider.notes && (
<p className="mt-3 text-xs text-muted-foreground line-clamp-2">
{provider.notes}
</p>
)}
</div>
);
}
@@ -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<UniversalProviderPreset | null>(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<UniversalProviderModels>({});
// 保存并同步确认弹窗
const [syncConfirmOpen, setSyncConfirmOpen] = useState(false);
const [pendingProvider, setPendingProvider] =
useState<UniversalProvider | null>(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 = (
<>
<Button variant="outline" onClick={onClose}>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
{isEditMode && onSaveAndSync ? (
<Button
onClick={handleSaveAndSyncClick}
disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}
>
<RefreshCw className="mr-1.5 h-4 w-4" />
{t("universalProvider.saveAndSync", { defaultValue: "保存并同步" })}
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}
>
{t("common.add", { defaultValue: "添加" })}
</Button>
)}
</>
);
return (
<FullScreenPanel
isOpen={isOpen}
title={
isEditMode
? t("universalProvider.edit", { defaultValue: "编辑统一供应商" })
: t("universalProvider.add", { defaultValue: "添加统一供应商" })
}
onClose={onClose}
footer={footer}
>
<div className="space-y-6">
{/* 预设选择(仅新建模式) */}
{!isEditMode && (
<div className="space-y-3">
<Label>
{t("universalProvider.selectPreset", {
defaultValue: "选择预设类型",
})}
</Label>
<div className="flex flex-wrap gap-2">
{universalProviderPresets.map((preset) => (
<button
key={preset.providerType}
type="button"
onClick={() => 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"
}`}
>
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={16}
/>
{preset.name}
</button>
))}
</div>
{selectedPreset?.description && (
<p className="text-xs text-muted-foreground">
{selectedPreset.description}
</p>
)}
</div>
)}
{/* 基本信息 */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
{t("universalProvider.name", { defaultValue: "名称" })}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("universalProvider.namePlaceholder", {
defaultValue: "例如:我的 NewAPI",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="baseUrl">
{t("universalProvider.baseUrl", { defaultValue: "API 地址" })}
</Label>
<Input
id="baseUrl"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">
{t("universalProvider.apiKey", { defaultValue: "API Key" })}
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="websiteUrl">
{t("universalProvider.websiteUrl", { defaultValue: "官网地址" })}
</Label>
<Input
id="websiteUrl"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
placeholder={t("universalProvider.websiteUrlPlaceholder", {
defaultValue: "https://example.com(可选,用于在列表中显示)",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">
{t("universalProvider.notes", { defaultValue: "备注" })}
</Label>
<Input
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("universalProvider.notesPlaceholder", {
defaultValue: "可选:添加备注信息",
})}
/>
</div>
</div>
{/* 应用启用 */}
<div className="space-y-3">
<Label>
{t("universalProvider.enabledApps", { defaultValue: "启用的应用" })}
</Label>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<ProviderIcon icon="claude" name="Claude" size={20} />
<span className="font-medium">Claude Code</span>
</div>
<Switch
checked={claudeEnabled}
onCheckedChange={setClaudeEnabled}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<ProviderIcon icon="openai" name="Codex" size={20} />
<span className="font-medium">OpenAI Codex</span>
</div>
<Switch
checked={codexEnabled}
onCheckedChange={setCodexEnabled}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<ProviderIcon icon="gemini" name="Gemini" size={20} />
<span className="font-medium">Gemini CLI</span>
</div>
<Switch
checked={geminiEnabled}
onCheckedChange={setGeminiEnabled}
/>
</div>
</div>
</div>
{/* 模型配置 */}
<div className="space-y-4">
<Label>
{t("universalProvider.modelConfig", { defaultValue: "模型配置" })}
</Label>
{/* Claude 模型 */}
{claudeEnabled && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2 font-medium">
<ProviderIcon icon="claude" name="Claude" size={16} />
Claude
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">
{t("universalProvider.model", { defaultValue: "主模型" })}
</Label>
<Input
value={models.claude?.model || ""}
onChange={(e) =>
updateModel("claude", "model", e.target.value)
}
placeholder="claude-sonnet-4-20250514"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Haiku</Label>
<Input
value={models.claude?.haikuModel || ""}
onChange={(e) =>
updateModel("claude", "haikuModel", e.target.value)
}
placeholder="claude-haiku-4-20250514"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Sonnet</Label>
<Input
value={models.claude?.sonnetModel || ""}
onChange={(e) =>
updateModel("claude", "sonnetModel", e.target.value)
}
placeholder="claude-sonnet-4-20250514"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Opus</Label>
<Input
value={models.claude?.opusModel || ""}
onChange={(e) =>
updateModel("claude", "opusModel", e.target.value)
}
placeholder="claude-sonnet-4-20250514"
/>
</div>
</div>
</div>
)}
{/* Codex 模型 */}
{codexEnabled && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2 font-medium">
<ProviderIcon icon="openai" name="Codex" size={16} />
Codex
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">
{t("universalProvider.model", { defaultValue: "模型" })}
</Label>
<Input
value={models.codex?.model || ""}
onChange={(e) =>
updateModel("codex", "model", e.target.value)
}
placeholder="gpt-4o"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Reasoning Effort</Label>
<Input
value={models.codex?.reasoningEffort || ""}
onChange={(e) =>
updateModel("codex", "reasoningEffort", e.target.value)
}
placeholder="high"
/>
</div>
</div>
</div>
)}
{/* Gemini 模型 */}
{geminiEnabled && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2 font-medium">
<ProviderIcon icon="gemini" name="Gemini" size={16} />
Gemini
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("universalProvider.model", { defaultValue: "模型" })}
</Label>
<Input
value={models.gemini?.model || ""}
onChange={(e) =>
updateModel("gemini", "model", e.target.value)
}
placeholder="gemini-2.5-pro"
/>
</div>
</div>
)}
</div>
{/* 配置 JSON 预览 */}
{isEditMode && (claudeEnabled || codexEnabled || geminiEnabled) && (
<div className="space-y-4">
<Label>
{t("universalProvider.configJsonPreview", {
defaultValue: "配置 JSON 预览",
})}
</Label>
<p className="text-xs text-muted-foreground">
{t("universalProvider.configJsonPreviewHint", {
defaultValue:
"以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)",
})}
</p>
{/* Claude JSON */}
{claudeConfigJson && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ProviderIcon icon="claude" name="Claude" size={16} />
Claude
</div>
<JsonEditor
value={JSON.stringify(claudeConfigJson, null, 2)}
onChange={() => {}}
height={180}
/>
</div>
)}
{/* Codex JSON */}
{codexConfigJson && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ProviderIcon icon="openai" name="Codex" size={16} />
Codex
</div>
<JsonEditor
value={JSON.stringify(codexConfigJson, null, 2)}
onChange={() => {}}
height={280}
/>
</div>
)}
{/* Gemini JSON */}
{geminiConfigJson && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ProviderIcon icon="gemini" name="Gemini" size={16} />
Gemini
</div>
<JsonEditor
value={JSON.stringify(geminiConfigJson, null, 2)}
onChange={() => {}}
height={140}
/>
</div>
)}
</div>
)}
</div>
{/* 保存并同步确认弹窗 */}
<ConfirmDialog
isOpen={syncConfirmOpen}
title={t("universalProvider.syncConfirmTitle", {
defaultValue: "同步统一供应商",
})}
message={t("universalProvider.syncConfirmDescription", {
defaultValue: `同步 "${name}" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?`,
name: name,
})}
confirmText={t("universalProvider.saveAndSync", {
defaultValue: "保存并同步",
})}
onConfirm={confirmSaveAndSync}
onCancel={() => {
setSyncConfirmOpen(false);
setPendingProvider(null);
}}
/>
</FullScreenPanel>
);
}
@@ -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<UniversalProvidersMap>({});
const [loading, setLoading] = useState(true);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingProvider, setEditingProvider] =
useState<UniversalProvider | null>(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 (
<div className="space-y-4">
{/* 头部 */}
<div className="flex items-center gap-2">
<Layers className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">
{t("universalProvider.title", { defaultValue: "统一供应商" })}
</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{providerList.length}
</span>
</div>
{/* 描述 */}
<p className="text-sm text-muted-foreground">
{t("universalProvider.description", {
defaultValue:
"统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
})}
</p>
{/* 供应商列表 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : providerList.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center">
<Layers className="mb-3 h-10 w-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
{t("universalProvider.empty", {
defaultValue: "还没有统一供应商",
})}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{t("universalProvider.emptyHint", {
defaultValue: "点击下方「添加统一供应商」按钮创建一个",
})}
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{providerList.map((provider) => (
<UniversalProviderCard
key={provider.id}
provider={provider}
onEdit={handleEdit}
onDelete={handleDeleteClick}
onSync={handleSyncClick}
/>
))}
</div>
)}
{/* 表单模态框 */}
<UniversalProviderFormModal
isOpen={isFormOpen}
onClose={() => {
setIsFormOpen(false);
setEditingProvider(null);
}}
onSave={handleSave}
onSaveAndSync={handleSaveAndSync}
editingProvider={editingProvider}
/>
{/* 删除确认对话框 */}
<ConfirmDialog
isOpen={deleteConfirm.open}
title={t("universalProvider.deleteConfirmTitle", {
defaultValue: "删除统一供应商",
})}
message={t("universalProvider.deleteConfirmDescription", {
defaultValue: `确定要删除 "${deleteConfirm.name}" 吗?这将同时删除它在各应用中生成的供应商配置。`,
name: deleteConfirm.name,
})}
confirmText={t("common.delete", { defaultValue: "删除" })}
onConfirm={handleDelete}
onCancel={() => setDeleteConfirm({ open: false, id: "", name: "" })}
/>
{/* 同步确认对话框 */}
<ConfirmDialog
isOpen={syncConfirm.open}
title={t("universalProvider.syncConfirmTitle", {
defaultValue: "同步统一供应商",
})}
message={t("universalProvider.syncConfirmDescription", {
defaultValue: `同步 "${syncConfirm.name}" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?`,
name: syncConfirm.name,
})}
confirmText={t("universalProvider.syncConfirm", {
defaultValue: "同步",
})}
onConfirm={handleSync}
onCancel={() => setSyncConfirm({ open: false, id: "", name: "" })}
/>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { UniversalProviderPanel } from "./UniversalProviderPanel";
export { UniversalProviderCard } from "./UniversalProviderCard";
export { UniversalProviderFormModal } from "./UniversalProviderFormModal";