mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
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:
+90
-11
@@ -43,9 +43,17 @@ import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
||||
type View =
|
||||
| "providers"
|
||||
| "settings"
|
||||
| "prompts"
|
||||
| "skills"
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal";
|
||||
|
||||
const DRAG_BAR_HEIGHT = 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
@@ -65,6 +73,22 @@ function App() {
|
||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||
|
||||
// 保存最后一个有效的 provider,用于动画退出期间显示内容
|
||||
const lastUsageProviderRef = useRef<Provider | null>(null);
|
||||
const lastEditingProviderRef = useRef<Provider | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (usageProvider) {
|
||||
lastUsageProviderRef.current = usageProvider;
|
||||
}
|
||||
}, [usageProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProvider) {
|
||||
lastEditingProviderRef.current = editingProvider;
|
||||
}
|
||||
}, [editingProvider]);
|
||||
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
const skillsPageRef = useRef<any>(null);
|
||||
@@ -129,6 +153,38 @@ function App() {
|
||||
};
|
||||
}, [activeApp, refetch]);
|
||||
|
||||
// 监听统一供应商同步事件,刷新所有应用的供应商列表
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unsubscribe = await listen("universal-provider-synced", async () => {
|
||||
// 统一供应商同步后刷新所有应用的供应商列表
|
||||
// 使用 invalidateQueries 使所有 providers 查询失效
|
||||
await queryClient.invalidateQueries({ queryKey: ["providers"] });
|
||||
// 同时更新托盘菜单
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to update tray menu", error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[App] Failed to subscribe universal-provider-synced event",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setupListener();
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// 应用启动时检测所有应用的环境变量冲突
|
||||
useEffect(() => {
|
||||
const checkEnvOnStartup = async () => {
|
||||
@@ -206,6 +262,21 @@ function App() {
|
||||
checkEnvOnSwitch();
|
||||
}, [activeApp]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setCurrentView("settings");
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleGlobalShortcut);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleGlobalShortcut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 打开网站链接
|
||||
const handleOpenWebsite = async (url: string) => {
|
||||
try {
|
||||
@@ -368,6 +439,12 @@ function App() {
|
||||
return (
|
||||
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||
);
|
||||
case "universal":
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 pt-4">
|
||||
<UniversalProviderPanel />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
@@ -499,6 +576,10 @@ function App() {
|
||||
{currentView === "skills" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
{currentView === "agents" && t("agents.title")}
|
||||
{currentView === "universal" &&
|
||||
t("universalProvider.title", {
|
||||
defaultValue: "统一供应商",
|
||||
})}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -533,7 +614,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 min-h-[40px]"
|
||||
className="flex items-center gap-2 h-[32px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
@@ -646,11 +727,7 @@ function App() {
|
||||
</header>
|
||||
|
||||
<main className="flex-1 pb-12 animate-fade-in ">
|
||||
<div
|
||||
className={cn("pb-12", currentView === "providers" ? "pt-6" : "pt-4")}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
<div className="pb-12">{renderContent()}</div>
|
||||
</main>
|
||||
|
||||
<AddProviderDialog
|
||||
@@ -662,7 +739,7 @@ function App() {
|
||||
|
||||
<EditProviderDialog
|
||||
open={Boolean(editingProvider)}
|
||||
provider={editingProvider}
|
||||
provider={lastEditingProviderRef.current}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingProvider(null);
|
||||
@@ -673,14 +750,16 @@ function App() {
|
||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||
/>
|
||||
|
||||
{usageProvider && (
|
||||
{lastUsageProviderRef.current && (
|
||||
<UsageScriptModal
|
||||
provider={usageProvider}
|
||||
provider={lastUsageProviderRef.current}
|
||||
appId={activeApp}
|
||||
isOpen={Boolean(usageProvider)}
|
||||
onClose={() => setUsageProvider(null)}
|
||||
onSave={(script) => {
|
||||
void saveUsageScript(usageProvider, script);
|
||||
if (usageProvider) {
|
||||
void saveUsageScript(usageProvider, script);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,4 +3,3 @@
|
||||
*/
|
||||
|
||||
export { ProxyPanel } from "./ProxyPanel";
|
||||
export { ProxySettingsDialog } from "./ProxySettingsDialog";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { UniversalProviderPanel } from "./UniversalProviderPanel";
|
||||
export { UniversalProviderCard } from "./UniversalProviderCard";
|
||||
export { UniversalProviderFormModal } from "./UniversalProviderFormModal";
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+140
-1
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
+139
-1
@@ -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": "以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)"
|
||||
}
|
||||
}
|
||||
|
||||
+140
-1
@@ -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": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -198,6 +198,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
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<string, IconMetadata> = {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>NewAPI</title><path d="M23.078 16.34c-.506 1.323-1.198 2.519-2.117 3.562-2.378 2.696-5.374 4.057-8.971 4.098a.037.037 0 01-.024-.01.041.041 0 01-.013-.023.041.041 0 01.003-.025.037.037 0 01.019-.019c1.886-.779 3.454-1.973 4.625-3.639a10.148 10.148 0 001.626-3.677c.217-.98.33-1.955.282-2.942-.048-1.018-.152-1.601-.484-2.565-.386-1.12-.915-2.16-1.627-3.089-.883-1.154-1.876-1.87-2.9-2.779-.995-.88-2.19-2.623-1.059-3.754.384-.384.997-.59 1.838-.621 2.478-.09 5.011 1.636 6.597 3.453.75.86 1.38 1.798 1.865 2.837.486 1.041.814 2.122.978 3.246.133.915.117 1.441.092 2.365a10.82 10.82 0 01-.73 3.582z" fill="url(#lobe-icons-new-api-fill-0)"></path><path d="M11.86.01a.041.041 0 01.009.049.038.038 0 01-.018.018C9.964.856 8.396 2.05 7.225 3.716a10.148 10.148 0 00-1.626 3.678c-.217.979-.33 1.955-.283 2.941.049 1.018.154 1.601.486 2.565.385 1.12.914 2.16 1.626 3.088.883 1.154 1.876 1.872 2.9 2.78.995.88 2.19 2.622 1.059 3.753-.385.385-.997.591-1.838.622-2.478.089-5.011-1.636-6.597-3.454-.75-.86-1.38-1.797-1.865-2.837a11.591 11.591 0 01-.978-3.246c-.133-.914-.117-1.44-.091-2.364.034-1.225.284-2.416.73-3.582.504-1.323 1.197-2.52 2.116-3.562C5.241 1.402 8.238.04 11.835 0c.009 0 .018.004.024.01z" fill="url(#lobe-icons-new-api-fill-1)"></path><path d="M8.721 11.903l2.455-.708.72-2.48a.066.066 0 01.127.002l.58 2.26c.776.437 1.65.755 2.622.956a.05.05 0 01.028.075.05.05 0 01-.024.019l-2.382.709a.163.163 0 00-.109.108l-.72 2.444a.034.034 0 01-.031.027.034.034 0 01-.034-.024l-.713-2.395a.183.183 0 00-.128-.128l-2.39-.705a.084.084 0 01-.044-.13.084.084 0 01.043-.03z" fill="url(#lobe-icons-new-api-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-new-api-fill-0" x1="17.889" x2="17.889" y1=".854" y2="24"><stop stop-color="#F85EAD"></stop><stop offset="1" stop-color="#FD75FD"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-new-api-fill-1" x1="5.936" x2="5.936" y1="0" y2="23.146"><stop offset=".332" stop-color="#11F5EF"></stop><stop offset="1" stop-color="#C738FB"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-new-api-fill-2" x1="11.961" x2="11.961" y1="8.666" y2="15.315"><stop offset=".332" stop-color="#11F5EF"></stop><stop offset="1" stop-color="#C738FB"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.2 KiB |
@@ -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";
|
||||
|
||||
@@ -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<UniversalProvidersMap> {
|
||||
return await invoke("get_universal_providers");
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个统一供应商
|
||||
*/
|
||||
async get(id: string): Promise<UniversalProvider | null> {
|
||||
return await invoke("get_universal_provider", { id });
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加或更新统一供应商
|
||||
*/
|
||||
async upsert(provider: UniversalProvider): Promise<boolean> {
|
||||
return await invoke("upsert_universal_provider", { provider });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除统一供应商
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
return await invoke("delete_universal_provider", { id });
|
||||
},
|
||||
|
||||
/**
|
||||
* 手动同步统一供应商到各应用
|
||||
*/
|
||||
async sync(id: string): Promise<boolean> {
|
||||
return await invoke("sync_universal_provider", { id });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<ProxyServerInfo> {
|
||||
return invoke("start_proxy_server");
|
||||
},
|
||||
|
||||
// 停止代理服务器并恢复配置
|
||||
async stopProxyWithRestore(): Promise<void> {
|
||||
return invoke("stop_proxy_with_restore");
|
||||
},
|
||||
|
||||
// 获取代理服务器状态
|
||||
async getProxyStatus(): Promise<ProxyStatus> {
|
||||
return invoke("get_proxy_status");
|
||||
},
|
||||
|
||||
// 检查代理服务器是否正在运行
|
||||
async isProxyRunning(): Promise<boolean> {
|
||||
return invoke("is_proxy_running");
|
||||
},
|
||||
|
||||
// 检查是否处于接管模式
|
||||
async isLiveTakeoverActive(): Promise<boolean> {
|
||||
return invoke("is_live_takeover_active");
|
||||
},
|
||||
|
||||
// 代理模式下切换供应商
|
||||
async switchProxyProvider(
|
||||
appType: string,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
return invoke("switch_proxy_provider", { appType, providerId });
|
||||
},
|
||||
|
||||
// ========== 接管状态 API ==========
|
||||
|
||||
// 获取各应用接管状态
|
||||
async getProxyTakeoverStatus(): Promise<ProxyTakeoverStatus> {
|
||||
return invoke("get_proxy_takeover_status");
|
||||
},
|
||||
|
||||
// 为指定应用开启/关闭接管
|
||||
async setProxyTakeoverForApp(
|
||||
appType: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_proxy_takeover_for_app", { appType, enabled });
|
||||
},
|
||||
|
||||
// ========== Legacy 代理配置 API (兼容) ==========
|
||||
|
||||
// 获取代理配置(旧版 v2 兼容接口)
|
||||
async getProxyConfig(): Promise<ProxyConfig> {
|
||||
return invoke("get_proxy_config");
|
||||
},
|
||||
|
||||
// 更新代理配置(旧版 v2 兼容接口)
|
||||
async updateProxyConfig(config: ProxyConfig): Promise<void> {
|
||||
return invoke("update_proxy_config", { config });
|
||||
},
|
||||
|
||||
// ========== v3+ 全局/应用级配置 API ==========
|
||||
|
||||
// 获取全局代理配置
|
||||
async getGlobalProxyConfig(): Promise<GlobalProxyConfig> {
|
||||
return invoke("get_global_proxy_config");
|
||||
},
|
||||
|
||||
// 更新全局代理配置
|
||||
async updateGlobalProxyConfig(config: GlobalProxyConfig): Promise<void> {
|
||||
return invoke("update_global_proxy_config", { config });
|
||||
},
|
||||
|
||||
// 获取指定应用的代理配置
|
||||
async getProxyConfigForApp(appType: string): Promise<AppProxyConfig> {
|
||||
return invoke("get_proxy_config_for_app", { appType });
|
||||
},
|
||||
|
||||
// 更新指定应用的代理配置
|
||||
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
|
||||
return invoke("update_proxy_config_for_app", { config });
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./queryClient";
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./proxy";
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -186,3 +186,61 @@ export interface McpConfigResponse {
|
||||
configPath: string;
|
||||
servers: Record<string, McpServer>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(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<string, UniversalProvider>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user