mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
feat: add Universal Provider feature (#348)
* feat: add Universal Provider feature - Add Universal Provider data structures and type definitions - Implement backend CRUD operations and sync functionality - Add frontend UI components (UniversalProviderPanel, Card, FormModal) - Add NewAPI icon and preset configuration - Support cross-app (Claude/Codex/Gemini) configuration sync - Add website URL field for providers - Implement real-time refresh via event notifications - Add i18n support (Chinese/English/Japanese) * feat: integrate universal provider presets into add provider dialog - Add universal provider presets (NewAPI, Custom Gateway) to preset selector - Show universal presets with Layers icon badge in preset selector - Open UniversalProviderFormModal when universal preset is clicked - Pass initialPreset to auto-fill form when opened from add dialog - Add i18n keys for addSuccess/addFailed messages - Keep separate universal provider panel for management * refactor: move universal provider management to add dialog - Remove Layers button from main navigation header - Add 'Manage' button next to universal provider presets - Open UniversalProviderPanel from within add provider dialog - Add i18n keys for 'manage' in all locales * style: display universal provider presets on separate line - Move universal provider section to a new row with border separator - Add label '统一供应商:' to clarify the section * style: unify universal provider label style with preset label - Use FormLabel component for consistent styling - Add background to 'Manage' button matching preset buttons - Update icon size and button padding for consistency * feat: add sync functionality and JSON preview for Universal Provider * fix: add missing in_failover_queue field to Provider structs After rebasing to main, the Provider struct gained a new `in_failover_queue` field. This fix adds the missing field to the three to_*_provider() methods in UniversalProvider. * refactor: redesign AddProviderDialog with tab-based layout - Add tabs to separate app-specific providers and universal providers - Move "Add Universal Provider" button from panel header to footer - Remove unused handleAdd callback and clean up imports - Update emptyHint i18n text to reference the footer button * fix: append /v1 suffix to Codex base_url in Universal Provider Codex uses OpenAI-compatible API which requires the /v1 endpoint suffix. The Universal Provider now automatically appends /v1 to base_url when generating Codex provider config if not already present. - Handle trailing slashes to avoid double slashes - Apply fix to both backend (to_codex_provider) and frontend preview * feat: auto-sync universal provider to apps on creation Previously, users had to manually click sync after adding a universal provider. Now it automatically syncs to Claude/Codex/Gemini on creation, providing a smoother user experience. --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
+51
-1
@@ -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
|
||||
@@ -145,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 () => {
|
||||
@@ -364,6 +404,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">
|
||||
@@ -494,6 +540,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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -36,10 +36,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-foreground">
|
||||
{label} {required && "*"}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
||||
@@ -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) {
|
||||
@@ -803,6 +808,8 @@ export function ProviderForm({
|
||||
categoryKeys={categoryKeys}
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={handlePresetChange}
|
||||
onUniversalPresetSelect={onUniversalPresetSelect}
|
||||
onManageUniversalProviders={onManageUniversalProviders}
|
||||
category={category}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
@@ -1135,5 +1137,49 @@
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
"exitEditMode": "編集モードを終了"
|
||||
},
|
||||
"provider": {
|
||||
"tabProvider": "プロバイダー",
|
||||
"tabUniversal": "統一プロバイダー",
|
||||
"noProviders": "まだプロバイダーがありません",
|
||||
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
|
||||
"currentlyUsing": "現在使用中",
|
||||
@@ -1135,5 +1137,48 @@
|
||||
"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": "以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
"exitEditMode": "退出编辑模式"
|
||||
},
|
||||
"provider": {
|
||||
"tabProvider": "供应商",
|
||||
"tabUniversal": "统一供应商",
|
||||
"noProviders": "还没有添加任何供应商",
|
||||
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
|
||||
"currentlyUsing": "当前使用",
|
||||
@@ -1135,5 +1137,49 @@
|
||||
"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",
|
||||
|
||||
@@ -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 |
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -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 {
|
||||
@@ -62,3 +66,44 @@ export const providersApi = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user