feat: 支持 API Key Provider 在 /v1/chat/completions 和 /v1/messages 端点

- 添加 get_enabled_api_keys_by_type 方法按 Provider 类型获取 API Keys
- 添加 get_next_api_key_by_type 方法支持按类型轮询负载均衡
- 在 chat_completions 端点添加 API Key Provider 回退支持
- 在 anthropic_messages 端点添加 API Key Provider 回退支持
- 支持自定义 base_url 的 AnthropicKey 使用 OpenAI 兼容格式调用
- 添加 convert_openai_response_to_anthropic 响应转换函数
- 更新版本号到 v0.28.0

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-04 19:14:29 +08:00
co-authored by Claude Opus 4.5
parent 5d5b8cc7df
commit 373ff8d2b5
100 changed files with 32251 additions and 2764 deletions
@@ -1,5 +1,15 @@
import React, { useState } from "react";
import { Bot, ChevronDown, Check, Box, Settings2 } from "lucide-react";
import React, { useState, useEffect } from "react";
import {
Bot,
ChevronDown,
Check,
Box,
Settings2,
Zap,
Sparkles,
Crown,
Wand2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
@@ -7,9 +17,55 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Navbar } from "../styles";
import { PROVIDER_CONFIG } from "../types";
import { cn } from "@/lib/utils";
import {
orchestratorApi,
type ServiceTier,
type PoolStats,
} from "@/lib/api/orchestrator";
// 服务等级配置
const TIER_CONFIG: Record<
ServiceTier,
{
label: string;
description: string;
icon: React.ReactNode;
color: string;
bgColor: string;
}
> = {
mini: {
label: "Mini",
description: "快速响应",
icon: <Zap className="w-3.5 h-3.5" />,
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-500/10",
},
pro: {
label: "Pro",
description: "均衡性能",
icon: <Sparkles className="w-3.5 h-3.5" />,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10",
},
max: {
label: "Max",
description: "最强能力",
icon: <Crown className="w-3.5 h-3.5" />,
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-500/10",
},
};
type SelectionMode = "simple" | "expert";
interface ChatNavbarProps {
providerType: string;
@@ -33,10 +89,62 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
onToggleSettings,
}) => {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<SelectionMode>("simple");
const [tier, setTier] = useState<ServiceTier>("pro");
const [poolStats, setPoolStats] = useState<PoolStats | null>(null);
const [orchestratorReady, setOrchestratorReady] = useState(false);
// 初始化 orchestrator
useEffect(() => {
const init = async () => {
try {
await orchestratorApi.init();
setOrchestratorReady(true);
const stats = await orchestratorApi.getPoolStats();
setPoolStats(stats);
} catch (err) {
console.warn("Orchestrator 初始化失败,使用专家模式:", err);
setMode("expert");
}
};
init();
}, []);
// 简单模式下选择等级时自动选择模型
const handleTierSelect = async (selectedTier: ServiceTier) => {
setTier(selectedTier);
setOpen(false);
if (!orchestratorReady) return;
try {
const result = await orchestratorApi.selectModel({ tier: selectedTier });
// 映射 orchestrator 的 provider_type 到 PROVIDER_CONFIG 的 key
const providerKey = mapProviderType(result.provider_type);
setProviderType(providerKey);
setModel(result.model_id);
} catch (err) {
console.error("模型选择失败:", err);
}
};
// 映射 provider type
const mapProviderType = (orchestratorType: string): string => {
const mapping: Record<string, string> = {
anthropic: "claude",
openai: "openai",
google: "gemini",
gemini: "gemini",
kiro: "kiro",
codex: "codex",
};
return mapping[orchestratorType.toLowerCase()] || orchestratorType;
};
const selectedProviderLabel =
PROVIDER_CONFIG[providerType]?.label || providerType;
const currentModels = PROVIDER_CONFIG[providerType]?.models || [];
const tierConfig = TIER_CONFIG[tier];
return (
<Navbar>
@@ -62,87 +170,206 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
aria-expanded={open}
className="h-9 px-3 gap-2 font-normal hover:bg-muted text-foreground"
>
<Bot size={16} className="text-primary" />
<span className="font-medium">{selectedProviderLabel}</span>
<span className="text-muted-foreground">/</span>
<span className="text-sm">{model || "Select Model"}</span>
{mode === "simple" && orchestratorReady ? (
<>
<span className={tierConfig.color}>{tierConfig.icon}</span>
<span className="font-medium">{tierConfig.label}</span>
<span className="text-muted-foreground text-xs">
({tierConfig.description})
</span>
</>
) : (
<>
<Bot size={16} className="text-primary" />
<span className="font-medium">{selectedProviderLabel}</span>
<span className="text-muted-foreground">/</span>
<span className="text-sm">{model || "Select Model"}</span>
</>
)}
<ChevronDown className="ml-1 h-3 w-3 text-muted-foreground opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[400px] p-0 bg-background/95 backdrop-blur-sm border-border shadow-lg"
className="w-[420px] p-0 bg-background/95 backdrop-blur-sm border-border shadow-lg"
align="center"
>
<div className="flex h-[300px]">
{/* Left Column: Providers */}
<div className="w-[140px] border-r bg-muted/30 p-2 flex flex-col gap-1 overflow-y-auto">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Providers
</div>
{Object.entries(PROVIDER_CONFIG).map(([key, config]) => (
<button
key={key}
onClick={() => {
setProviderType(key);
// Auto-select first model if available
if (config.models.length > 0) {
setModel(config.models[0]);
} else {
setModel("");
}
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
providerType === key
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
{config.label}
{providerType === key && (
<div className="w-1 h-1 rounded-full bg-primary" />
)}
</button>
))}
</div>
{/* Right Column: Models */}
<div className="flex-1 p-2 flex flex-col overflow-hidden">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Models
</div>
<ScrollArea className="flex-1">
<div className="space-y-1 p-1">
{currentModels.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
No models available
</div>
) : (
currentModels.map((m) => (
<button
key={m}
onClick={() => {
setModel(m);
setOpen(false);
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
model === m
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
{m}
{model === m && (
<Check size={14} className="text-primary" />
)}
</button>
))
)}
</div>
</ScrollArea>
{/* Mode Toggle */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<span className="text-xs font-medium text-muted-foreground">
选择模式
</span>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={mode === "simple" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setMode("simple")}
disabled={!orchestratorReady}
>
<Wand2 className="w-3 h-3 mr-1" />
简单
</Button>
</TooltipTrigger>
<TooltipContent>Mini/Pro/Max 三档智能选择</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={mode === "expert" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setMode("expert")}
>
<Settings2 className="w-3 h-3 mr-1" />
专家
</Button>
</TooltipTrigger>
<TooltipContent>直接选择 Provider 和模型</TooltipContent>
</Tooltip>
</div>
</div>
{mode === "simple" && orchestratorReady ? (
/* Simple Mode: Tier Selection */
<div className="p-3">
<div className="grid grid-cols-3 gap-2">
{(Object.keys(TIER_CONFIG) as ServiceTier[]).map((t) => {
const config = TIER_CONFIG[t];
const count =
poolStats?.[`${t}_count` as keyof PoolStats] ?? 0;
const isSelected = tier === t;
return (
<button
key={t}
onClick={() => handleTierSelect(t)}
className={cn(
"flex flex-col items-center p-3 rounded-lg border transition-all",
isSelected
? cn(
"border-primary/50",
config.bgColor,
"ring-2 ring-primary/20",
)
: "border-border hover:bg-muted/50",
count === 0 && "opacity-50 cursor-not-allowed",
)}
disabled={count === 0}
>
<span
className={cn(
"mb-1",
isSelected ? config.color : "text-muted-foreground",
)}
>
{config.icon}
</span>
<span
className={cn(
"font-medium text-sm",
isSelected ? config.color : "text-foreground",
)}
>
{config.label}
</span>
<span className="text-xs text-muted-foreground">
{config.description}
</span>
{poolStats && (
<span className="text-xs text-muted-foreground mt-1">
{count} 模型
</span>
)}
</button>
);
})}
</div>
{/* Current Selection Info */}
{model && (
<div className="mt-3 p-2 rounded-md bg-muted/50 text-xs">
<span className="text-muted-foreground">当前模型: </span>
<span className="font-medium">
{selectedProviderLabel} / {model}
</span>
</div>
)}
</div>
) : (
/* Expert Mode: Provider/Model Selection */
<div className="flex h-[300px]">
{/* Left Column: Providers */}
<div className="w-[140px] border-r bg-muted/30 p-2 flex flex-col gap-1 overflow-y-auto">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Providers
</div>
{Object.entries(PROVIDER_CONFIG).map(([key, config]) => (
<button
key={key}
onClick={() => {
setProviderType(key);
// Auto-select first model if available
if (config.models.length > 0) {
setModel(config.models[0]);
} else {
setModel("");
}
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
providerType === key
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
{config.label}
{providerType === key && (
<div className="w-1 h-1 rounded-full bg-primary" />
)}
</button>
))}
</div>
{/* Right Column: Models */}
<div className="flex-1 p-2 flex flex-col overflow-hidden">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Models
</div>
<ScrollArea className="flex-1">
<div className="space-y-1 p-1">
{currentModels.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
No models available
</div>
) : (
currentModels.map((m) => (
<button
key={m}
onClick={() => {
setModel(m);
setOpen(false);
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
model === m
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
{m}
{model === m && (
<Check size={14} className="text-primary" />
)}
</button>
))
)}
</div>
</ScrollArea>
</div>
</div>
)}
</PopoverContent>
</Popover>
</div>
@@ -1,7 +1,6 @@
import React, { useState } from "react";
import styled, { keyframes, css } from "styled-components";
import {
Sparkles,
ArrowRight,
ImageIcon,
Video,
@@ -37,10 +36,6 @@ import iconToutiao from "@/assets/platforms/toutiao.png";
import iconJuejin from "@/assets/platforms/juejin.png";
import iconCsdn from "@/assets/platforms/csdn.png";
import modelGemini from "@/assets/models/gemini.png";
import modelClaude from "@/assets/models/claude.png";
import modelDeepseek from "@/assets/models/deepseek.png";
// --- Animations ---
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
@@ -317,7 +312,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
// Local state for parameters (Mocking visual state)
const [platform, setPlatform] = useState("xiaohongshu");
const [model, setModel] = useState("gemini");
const [ratio, setRatio] = useState("3:4");
const [style, setStyle] = useState("minimal");
const [depth, setDepth] = useState("deep");
@@ -325,13 +319,12 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const handleSend = () => {
if (!input.trim()) return;
let prefix = "";
if (activeTab === "social")
prefix = `[社媒创作: ${platform}, Model: ${model}] `;
if (activeTab === "social") prefix = `[社媒创作: ${platform}] `;
if (activeTab === "image") prefix = `[图文生成: ${ratio}, ${style}] `;
if (activeTab === "video") prefix = `[视频脚本] `;
if (activeTab === "office") prefix = `[办公文档] `;
if (activeTab === "knowledge")
prefix = `[知识探索: ${depth === "deep" ? "深度" : "快速"}, Model: ${model}] `;
prefix = `[知识探索: ${depth === "deep" ? "深度" : "快速"}] `;
if (activeTab === "planning") prefix = `[计划规划] `;
onSend(prefix + input);
@@ -386,22 +379,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
return val;
};
// Helper to get model icon
const getModelIcon = (val: string) => {
if (val === "gemini") return modelGemini;
if (val === "claude") return modelClaude;
if (val === "deepseek") return modelDeepseek;
return undefined;
};
// Helper to get model label
const getModelLabel = (val: string) => {
if (val === "gemini") return "Gemini 3.0 Pro";
if (val === "claude") return "Claude 3.5 Sonnet";
if (val === "deepseek") return "DeepSeek V3";
return val;
};
return (
<Container>
<ContentWrapper>
@@ -634,40 +611,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
</>
)}
{/* Model Selector using Popover for better control or just a Select */}
<Select value={model} onValueChange={setModel} closeOnMouseLeave>
<SelectTrigger className="h-8 text-xs bg-background border shadow-sm min-w-[200px] px-2">
<div className="flex items-center gap-1.5 text-muted-foreground">
{getModelIcon(model) ? (
<img src={getModelIcon(model)} className="w-3.5 h-3.5" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}
<span>{getModelLabel(model)}</span>
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="gemini">
<div className="flex items-center gap-2">
<img src={modelGemini} className="w-4 h-4" /> Gemini 3.0
Pro
</div>
</SelectItem>
<SelectItem value="claude">
<div className="flex items-center gap-2">
<img src={modelClaude} className="w-4 h-4" /> Claude 3.5
Sonnet
</div>
</SelectItem>
<SelectItem value="deepseek">
<div className="flex items-center gap-2">
<img src={modelDeepseek} className="w-4 h-4" /> DeepSeek
V3
</div>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
+297 -72
View File
@@ -27,6 +27,10 @@ import {
NetworkInfo,
} from "@/hooks/useTauri";
import { providerPoolApi, ProviderPoolOverview } from "@/lib/api/providerPool";
import {
apiKeyProviderApi,
ProviderWithKeysDisplay,
} from "@/lib/api/apiKeyProvider";
interface TestState {
endpoint: string;
@@ -38,6 +42,17 @@ interface TestState {
type TabId = "server" | "routes" | "logs";
// 可用的 Provider 信息(合并 OAuth 凭证池和 API Key Provider)
interface AvailableProvider {
id: string;
label: string;
iconType: string;
source: "oauth" | "api_key" | "both";
oauthCount: number;
apiKeyCount: number;
totalCount: number;
}
export function ApiServerPage() {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [config, setConfig] = useState<Config | null>(null);
@@ -172,15 +187,53 @@ export function ApiServerPage() {
};
const providerLabels: Record<string, string> = {
kiro: "Kiro (AWS)",
gemini: "Gemini (Google)",
qwen: "Qwen (阿里)",
antigravity: "Antigravity (Gemini 3 Pro)",
// OAuth 凭证池类型
kiro: "Kiro",
gemini: "Gemini",
qwen: "Qwen",
antigravity: "Antigravity",
claude: "Claude",
codex: "Codex",
iflow: "iFlow",
claude_oauth: "Claude OAuth",
vertex: "Vertex AI",
gemini_api_key: "Gemini API Key",
// API Key Provider 类型
openai: "OpenAI",
claude: "Claude (Anthropic)",
anthropic: "Anthropic",
azure_openai: "Azure OpenAI",
aws_bedrock: "AWS Bedrock",
ollama: "Ollama",
};
// Provider ID 到图标类型的映射
const providerIconMap: Record<string, string> = {
// OAuth 凭证池类型
kiro: "kiro",
gemini: "gemini",
qwen: "qwen",
antigravity: "gemini",
claude: "claude",
codex: "openai",
iflow: "iflow",
claude_oauth: "claude",
vertex: "gemini",
gemini_api_key: "gemini",
// API Key Provider 类型
openai: "openai",
anthropic: "claude",
azure_openai: "openai",
aws_bedrock: "claude",
ollama: "ollama",
};
const [poolOverview, setPoolOverview] = useState<ProviderPoolOverview[]>([]);
const [apiKeyProviders, setApiKeyProviders] = useState<
ProviderWithKeysDisplay[]
>([]);
const [availableProviders, setAvailableProviders] = useState<
AvailableProvider[]
>([]);
const [providerSwitchMsg, setProviderSwitchMsg] = useState<string | null>(
null,
);
@@ -195,10 +248,122 @@ export function ApiServerPage() {
}
};
// 加载 API Key Provider 数据
const loadApiKeyProviders = async () => {
try {
const providers = await apiKeyProviderApi.getProviders();
setApiKeyProviders(providers);
} catch (e) {
console.error("Failed to load API Key providers:", e);
}
};
// 合并 OAuth 凭证池和 API Key Provider,生成可用 Provider 列表
const buildAvailableProviders = () => {
const providerMap = new Map<string, AvailableProvider>();
// 添加 OAuth 凭证池中有凭证的 Provider
poolOverview.forEach((overview) => {
const enabledCredentials = overview.credentials.filter(
(c) => !c.is_disabled,
);
if (enabledCredentials.length > 0) {
const id = overview.provider_type;
const existing = providerMap.get(id);
if (existing) {
existing.oauthCount = enabledCredentials.length;
existing.totalCount = existing.oauthCount + existing.apiKeyCount;
existing.source =
existing.apiKeyCount > 0 && existing.oauthCount > 0
? "both"
: "oauth";
} else {
providerMap.set(id, {
id,
label: providerLabels[id] || id,
iconType: providerIconMap[id] || "openai",
source: "oauth",
oauthCount: enabledCredentials.length,
apiKeyCount: 0,
totalCount: enabledCredentials.length,
});
}
}
});
// 添加 API Key Provider 中有 API Key 的 Provider
apiKeyProviders.forEach((provider) => {
const enabledKeys = provider.api_keys.filter((k) => k.enabled);
if (enabledKeys.length > 0 && provider.enabled) {
// 将 API Key Provider 类型映射到统一的 ID
const id = mapApiKeyProviderToId(provider.type);
const existing = providerMap.get(id);
if (existing) {
existing.apiKeyCount = enabledKeys.length;
existing.totalCount = existing.oauthCount + existing.apiKeyCount;
existing.source =
existing.oauthCount > 0 && existing.apiKeyCount > 0
? "both"
: "api_key";
} else {
providerMap.set(id, {
id,
label: providerLabels[id] || provider.name,
iconType: providerIconMap[id] || "openai",
source: "api_key",
oauthCount: 0,
apiKeyCount: enabledKeys.length,
totalCount: enabledKeys.length,
});
}
}
});
// 转换为数组并按凭证数量排序
const providers = Array.from(providerMap.values()).sort(
(a, b) => b.totalCount - a.totalCount,
);
setAvailableProviders(providers);
};
// 将 API Key Provider 类型映射到 Provider ID
// API Key Provider 类型直接使用自己的 ID,不合并到 OAuth 凭证池类型
const mapApiKeyProviderToId = (providerType: string): string => {
switch (providerType.toLowerCase()) {
case "openai":
case "openai-response":
return "openai";
case "anthropic":
return "anthropic";
case "gemini":
return "gemini_api_key";
case "azure-openai":
return "azure_openai";
case "vertexai":
return "vertex";
case "aws-bedrock":
return "aws_bedrock";
case "ollama":
return "ollama";
default:
return providerType.toLowerCase();
}
};
// 当 poolOverview 或 apiKeyProviders 变化时,重新构建可用 Provider 列表
useEffect(() => {
buildAvailableProviders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poolOverview, apiKeyProviders]);
useEffect(() => {
loadPoolOverview();
loadApiKeyProviders();
// 定时刷新凭证池数据,以便使用次数能够更新
const poolInterval = setInterval(loadPoolOverview, 5000);
const poolInterval = setInterval(() => {
loadPoolOverview();
loadApiKeyProviders();
}, 5000);
return () => clearInterval(poolInterval);
}, []);
@@ -219,18 +384,25 @@ export function ApiServerPage() {
const freshOverview = await providerPoolApi.getOverview();
setPoolOverview(freshOverview);
// 获取该类型下的凭证数量
const typeOverview = freshOverview.find(
(o) => o.provider_type === providerId,
);
const credCount = typeOverview?.stats.total || 0;
const healthyCount = typeOverview?.stats.healthy || 0;
// 获取该 Provider 的凭证信息
const provider = availableProviders.find((p) => p.id === providerId);
const label = providerLabels[providerId] || providerId;
setProviderSwitchMsg(
`已切换到 ${label}` +
(credCount > 0 ? `(${healthyCount}/${credCount} 可用)` : ""),
);
if (provider) {
const parts = [];
if (provider.oauthCount > 0) {
parts.push(`${provider.oauthCount} OAuth`);
}
if (provider.apiKeyCount > 0) {
parts.push(`${provider.apiKeyCount} API Key`);
}
setProviderSwitchMsg(
`已切换到 ${label}` +
(parts.length > 0 ? `(${parts.join(", ")})` : ""),
);
} else {
setProviderSwitchMsg(`已切换到 ${label}`);
}
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
setProviderSwitchMsg(`切换失败: ${errMsg}`);
@@ -565,7 +737,7 @@ export function ApiServerPage() {
</div>
</div>
{/* Default Provider - 紧凑版 */}
{/* Default Provider - 动态显示有凭证的 Provider */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<span className="font-medium text-sm">默认 Provider</span>
@@ -576,26 +748,14 @@ export function ApiServerPage() {
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{(
[
{ id: "kiro", label: "Kiro", iconType: "kiro" },
{ id: "gemini", label: "Gemini", iconType: "gemini" },
{ id: "qwen", label: "Qwen", iconType: "qwen" },
{
id: "antigravity",
label: "Antigravity",
iconType: "gemini",
},
{ id: "openai", label: "OpenAI", iconType: "openai" },
{ id: "claude", label: "Claude", iconType: "claude" },
] as const
).map((p) => {
const overview = poolOverview.find(
(o) => o.provider_type === p.id,
);
const count = overview?.stats.total || 0;
return (
{availableProviders.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
暂无可用凭证,请先在凭证池或 API Key 设置中添加凭证
</div>
) : (
<div className="flex flex-wrap gap-2">
{availableProviders.map((p) => (
<button
key={p.id}
onClick={() => handleSetDefaultProvider(p.id)}
@@ -606,58 +766,123 @@ export function ApiServerPage() {
: "border-border bg-card hover:bg-muted text-muted-foreground hover:text-foreground"
} disabled:opacity-50`}
>
<ProviderIcon providerType={p.iconType} size={14} />
<ProviderIcon
providerType={
p.iconType as Parameters<
typeof ProviderIcon
>[0]["providerType"]
}
size={14}
/>
{p.label}
{count > 0 && (
<span className="text-xs opacity-70">({count})</span>
)}
<span className="text-xs opacity-70">
({p.totalCount}
{p.source === "both" && (
<span className="ml-0.5">混合</span>
)}
{p.source === "api_key" && (
<span className="ml-0.5">Key</span>
)}
)
</span>
</button>
);
})}
</div>
))}
</div>
)}
{/* 当前选中类型的凭证列表 */}
{(() => {
const _currentProvider = availableProviders.find(
(p) => p.id === defaultProvider,
);
const currentOverview = poolOverview.find(
(o) => o.provider_type === defaultProvider,
);
const allCredentials = currentOverview?.credentials || [];
// 过滤掉禁用的凭证,只显示启用的凭证
const credentials = allCredentials.filter(
(cred) => !cred.is_disabled,
const oauthCredentials = (
currentOverview?.credentials || []
).filter((cred) => !cred.is_disabled);
// 获取 API Key 凭证 - 查找所有映射到当前 defaultProvider 的 API Key Provider
const matchingApiKeyProviders = apiKeyProviders.filter((p) => {
const mappedId = mapApiKeyProviderToId(p.type);
return mappedId === defaultProvider && p.enabled;
});
const apiKeys = matchingApiKeyProviders.flatMap((p) =>
p.api_keys.filter((k) => k.enabled),
);
if (credentials.length === 0) {
if (oauthCredentials.length === 0 && apiKeys.length === 0) {
// 只有当没有任何凭证时才显示提示
return (
<div className="mt-4 rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
当前类型无可用凭证,请先在凭证池中添加
</div>
);
}
return (
<div className="mt-4 space-y-2">
<p className="text-xs text-muted-foreground">
当前可用凭证 ({credentials.length}):
</p>
<div className="space-y-1">
{credentials.map((cred) => (
<div
key={cred.uuid}
className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
cred.is_healthy ? "bg-green-500" : "bg-yellow-500"
}`}
/>
<span>{cred.name || cred.uuid.slice(0, 8)}</span>
</div>
<span className="text-xs text-muted-foreground">
使用 {cred.usage_count} 次
</span>
<div className="mt-4 space-y-3">
{/* OAuth 凭证 */}
{oauthCredentials.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
OAuth 凭证 ({oauthCredentials.length}):
</p>
<div className="space-y-1">
{oauthCredentials.map((cred) => (
<div
key={cred.uuid}
className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
cred.is_healthy
? "bg-green-500"
: "bg-yellow-500"
}`}
/>
<span>{cred.name || cred.uuid.slice(0, 8)}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
OAuth
</span>
</div>
<span className="text-xs text-muted-foreground">
使用 {cred.usage_count} 次
</span>
</div>
))}
</div>
))}
</div>
</div>
)}
{/* API Key 凭证 */}
{apiKeys.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
API Key ({apiKeys.length}):
</p>
<div className="space-y-1">
{apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-green-500" />
<span>{key.alias || key.api_key_masked}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
API Key
</span>
</div>
<span className="text-xs text-muted-foreground">
使用 {key.usage_count} 次
</span>
</div>
))}
</div>
</div>
)}
</div>
);
})()}
@@ -0,0 +1,59 @@
/**
* 模式切换组件 - 简单模式/专家模式
*/
import { Settings2, Wand2 } from "lucide-react";
import { cn } from "@/lib/utils";
export type SelectionMode = "simple" | "expert";
interface ModeToggleProps {
/** 当前模式 */
mode: SelectionMode;
/** 模式变化回调 */
onModeChange: (mode: SelectionMode) => void;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
}
export function ModeToggle({
mode,
onModeChange,
disabled = false,
className,
}: ModeToggleProps) {
return (
<div className={cn("flex items-center gap-2", className)}>
<button
type="button"
onClick={() => onModeChange("simple")}
disabled={disabled}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
mode === "simple"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Wand2 className="h-4 w-4" />
<span>简单</span>
</button>
<button
type="button"
onClick={() => onModeChange("expert")}
disabled={disabled}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors",
mode === "expert"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Settings2 className="h-4 w-4" />
<span>专家</span>
</button>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* 模型列表组件 - 显示可用模型
*/
import { Check, AlertCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AvailableModel } from "@/lib/api/orchestrator";
interface ModelListProps {
/** 模型列表 */
models: AvailableModel[];
/** 选中的模型 ID */
selectedModelId?: string;
/** 选择模型回调 */
onSelectModel?: (model: AvailableModel) => void;
/** 是否加载中 */
loading?: boolean;
/** 错误信息 */
error?: string | null;
/** 自定义类名 */
className?: string;
}
export function ModelList({
models,
selectedModelId,
onSelectModel,
loading = false,
error = null,
className,
}: ModelListProps) {
if (loading) {
return (
<div className={cn("flex items-center justify-center py-8", className)}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载模型列表...</span>
</div>
);
}
if (error) {
return (
<div
className={cn(
"flex items-center justify-center py-8 text-destructive",
className,
)}
>
<AlertCircle className="h-5 w-5 mr-2" />
<span>{error}</span>
</div>
);
}
if (models.length === 0) {
return (
<div className={cn("text-center py-8 text-muted-foreground", className)}>
<p>暂无可用模型</p>
<p className="text-sm mt-1">请先添加凭证</p>
</div>
);
}
return (
<div className={cn("space-y-2", className)}>
{models.map((model) => {
const isSelected = model.model_id === selectedModelId;
return (
<button
key={`${model.model_id}-${model.credential_id}`}
type="button"
onClick={() => onSelectModel?.(model)}
className={cn(
"w-full flex items-center justify-between p-3 rounded-lg border transition-colors",
"hover:bg-muted/50",
isSelected ? "border-primary bg-primary/5" : "border-border",
!model.is_healthy && "opacity-60",
)}
>
<div className="flex items-center gap-3">
{/* 选中指示器 */}
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground",
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{/* 模型信息 */}
<div className="text-left">
<div className="font-medium text-sm">{model.display_name}</div>
<div className="text-xs text-muted-foreground">
{model.provider_type}
{model.context_length &&
` · ${formatContextLength(model.context_length)}`}
</div>
</div>
</div>
{/* 状态和能力标签 */}
<div className="flex items-center gap-2">
{model.supports_vision && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
视觉
</span>
)}
{model.supports_tools && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
工具
</span>
)}
{!model.is_healthy && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300">
不健康
</span>
)}
{model.current_load !== undefined && (
<LoadIndicator load={model.current_load} />
)}
</div>
</button>
);
})}
</div>
);
}
/** 负载指示器 */
function LoadIndicator({ load }: { load: number }) {
const color =
load < 30 ? "bg-green-500" : load < 70 ? "bg-yellow-500" : "bg-red-500";
return (
<div className="flex items-center gap-1">
<div className="w-8 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full", color)}
style={{ width: `${load}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{load}%</span>
</div>
);
}
/** 格式化上下文长度 */
function formatContextLength(length: number): string {
if (length >= 1000000) {
return `${(length / 1000000).toFixed(1)}M`;
}
if (length >= 1000) {
return `${(length / 1000).toFixed(0)}K`;
}
return String(length);
}
@@ -0,0 +1,201 @@
/**
* 统一模型选择器组件
*
* 整合简单模式(Mini/Pro/Max)和专家模式(直接选择模型)
*/
import { useState } from "react";
import { RefreshCw, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
import { TierSelector } from "./TierSelector";
import { ModeToggle, type SelectionMode } from "./ModeToggle";
import { ModelList } from "./ModelList";
import {
useOrchestrator,
useModelSelection,
type ServiceTier,
type AvailableModel,
type SelectionResult,
} from "@/lib/api/orchestrator";
interface ModelSelectorProps {
/** 初始模式 */
initialMode?: SelectionMode;
/** 初始等级 */
initialTier?: ServiceTier;
/** 选择模型回调 */
onSelect?: (result: SelectionResult) => void;
/** 是否显示模式切换 */
showModeToggle?: boolean;
/** 是否显示统计信息 */
showStats?: boolean;
/** 紧凑模式 */
compact?: boolean;
/** 自定义类名 */
className?: string;
}
export function ModelSelector({
initialMode = "simple",
initialTier = "pro",
onSelect,
showModeToggle = true,
showStats = true,
compact = false,
className,
}: ModelSelectorProps) {
const [mode, setMode] = useState<SelectionMode>(initialMode);
const [selectedModel, setSelectedModel] = useState<AvailableModel | null>(
null,
);
// 使用编排器状态
const {
initialized: _initialized,
loading: orchestratorLoading,
error: orchestratorError,
poolStats,
refreshStats,
} = useOrchestrator();
// 使用模型选择
const {
tier,
setTier,
models,
loading: modelsLoading,
error: modelsError,
selectModel,
refreshModels,
} = useModelSelection(initialTier);
// 简单模式下自动选择模型
const handleTierChange = async (newTier: ServiceTier) => {
setTier(newTier);
if (mode === "simple") {
try {
const result = await selectModel({ tier: newTier });
onSelect?.(result);
} catch (err) {
console.error("模型选择失败:", err);
}
}
};
// 专家模式下手动选择模型
const handleModelSelect = async (model: AvailableModel) => {
setSelectedModel(model);
try {
const result = await selectModel({
tier,
preferred_provider: model.provider_type,
});
onSelect?.(result);
} catch (err) {
console.error("模型选择失败:", err);
}
};
// 刷新数据
const handleRefresh = () => {
refreshStats();
refreshModels();
};
const loading = orchestratorLoading || modelsLoading;
const error = orchestratorError || modelsError;
return (
<div className={cn("space-y-4", className)}>
{/* 头部:模式切换和刷新 */}
<div className="flex items-center justify-between">
{showModeToggle && (
<ModeToggle mode={mode} onModeChange={setMode} disabled={loading} />
)}
<div className="flex items-center gap-2">
{/* 统计信息 */}
{showStats && poolStats && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Activity className="h-3 w-3" />
<span>
{poolStats.healthy_count}/{poolStats.total_count} 可用
</span>
</div>
)}
{/* 刷新按钮 */}
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
title="刷新"
>
<RefreshCw
className={cn(
"h-4 w-4 text-muted-foreground",
loading && "animate-spin",
)}
/>
</button>
</div>
</div>
{/* 等级选择器 */}
<TierSelector
value={tier}
onChange={handleTierChange}
disabled={loading}
modelCounts={
poolStats
? {
mini: poolStats.mini_count,
pro: poolStats.pro_count,
max: poolStats.max_count,
}
: undefined
}
compact={compact}
/>
{/* 专家模式:显示模型列表 */}
{mode === "expert" && (
<ModelList
models={models}
selectedModelId={selectedModel?.model_id}
onSelectModel={handleModelSelect}
loading={modelsLoading}
error={modelsError}
/>
)}
{/* 简单模式:显示当前选择 */}
{mode === "simple" && selectedModel && (
<div className="p-3 rounded-lg bg-muted/50 border">
<div className="text-sm font-medium">
{selectedModel.display_name}
</div>
<div className="text-xs text-muted-foreground">
{selectedModel.provider_type}
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
{error}
</div>
)}
</div>
);
}
// 导出子组件
export { TierSelector } from "./TierSelector";
export { ModeToggle } from "./ModeToggle";
export { ModelList } from "./ModelList";
export type { SelectionMode } from "./ModeToggle";
@@ -0,0 +1,159 @@
/**
* 服务等级选择器 - Mini/Pro/Max 三档选择
*
* 提供类似 v0 的简洁模式选择体验
*/
import React from "react";
import { Zap, Sparkles, Crown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ServiceTier } from "@/lib/api/orchestrator";
interface TierOption {
id: ServiceTier;
label: string;
description: string;
icon: React.ReactNode;
color: string;
bgColor: string;
borderColor: string;
}
const tierOptions: TierOption[] = [
{
id: "mini",
label: "Mini",
description: "快速响应",
icon: <Zap className="h-4 w-4" />,
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-950",
borderColor: "border-green-200 dark:border-green-800",
},
{
id: "pro",
label: "Pro",
description: "均衡性能",
icon: <Sparkles className="h-4 w-4" />,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-950",
borderColor: "border-blue-200 dark:border-blue-800",
},
{
id: "max",
label: "Max",
description: "最强能力",
icon: <Crown className="h-4 w-4" />,
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-950",
borderColor: "border-purple-200 dark:border-purple-800",
},
];
interface TierSelectorProps {
/** 当前选中的等级 */
value: ServiceTier;
/** 等级变化回调 */
onChange: (tier: ServiceTier) => void;
/** 是否禁用 */
disabled?: boolean;
/** 各等级的模型数量 */
modelCounts?: {
mini: number;
pro: number;
max: number;
};
/** 紧凑模式 */
compact?: boolean;
/** 自定义类名 */
className?: string;
}
export function TierSelector({
value,
onChange,
disabled = false,
modelCounts,
compact = false,
className,
}: TierSelectorProps) {
return (
<div className={cn("flex gap-2", compact ? "gap-1" : "gap-2", className)}>
{tierOptions.map((option) => {
const isSelected = value === option.id;
const count = modelCounts?.[option.id];
const hasModels = count === undefined || count > 0;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
disabled={disabled || !hasModels}
className={cn(
"flex-1 rounded-lg border px-3 py-2 transition-all",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
compact ? "px-2 py-1.5" : "px-3 py-2",
isSelected
? cn(
option.borderColor,
option.bgColor,
option.color,
"ring-2 ring-offset-1",
option.id === "mini" && "ring-green-500/50",
option.id === "pro" && "ring-blue-500/50",
option.id === "max" && "ring-purple-500/50",
)
: cn(
"border-border hover:bg-muted",
!hasModels && "opacity-50 cursor-not-allowed",
),
)}
>
<div className="flex items-center justify-center gap-1.5">
<span
className={cn(
isSelected ? option.color : "text-muted-foreground",
)}
>
{option.icon}
</span>
<span
className={cn(
"font-medium",
compact ? "text-xs" : "text-sm",
isSelected ? option.color : "text-foreground",
)}
>
{option.label}
</span>
{count !== undefined && (
<span
className={cn(
"text-xs",
isSelected ? option.color : "text-muted-foreground",
)}
>
({count})
</span>
)}
</div>
{!compact && (
<p
className={cn(
"text-xs mt-0.5",
isSelected ? option.color : "text-muted-foreground",
)}
>
{option.description}
</p>
)}
</button>
);
})}
</div>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { tierOptions };
export type { TierOption };
+11
View File
@@ -0,0 +1,11 @@
/**
* 模型选择器组件导出
*/
export { ModelSelector } from "./ModelSelector";
export { TierSelector, tierOptions } from "./TierSelector";
export { ModeToggle } from "./ModeToggle";
export { ModelList } from "./ModelList";
export type { SelectionMode } from "./ModeToggle";
export type { TierOption } from "./TierSelector";
@@ -0,0 +1,581 @@
/**
* @file OAuth Provider 插件容器组件
* @description 专门用于 OAuth Provider 插件的容器,整合 SDK 和 UI 系统
* @module components/plugins/OAuthPluginContainer
*/
import React, { useState, useCallback } from "react";
import {
Loader2,
AlertCircle,
RefreshCw,
Settings2,
Key,
Plus,
FileText,
FolderOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Modal } from "@/components/Modal";
import { usePluginSDK } from "@/lib/plugin-sdk";
import { PluginUIContainer } from "@/lib/plugin-ui";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import type { PluginId } from "@/lib/plugin-sdk/types";
import type { CredentialInfo } from "@/lib/plugin-sdk/types";
interface OAuthPluginContainerProps {
/** 插件 ID */
pluginId: PluginId;
/** 插件显示名称 */
displayName: string;
/** 插件描述 */
description?: string;
/** 插件版本 */
version?: string;
/** 是否启用 */
enabled?: boolean;
/** 自定义类名 */
className?: string;
/** 启用/禁用回调 */
onToggleEnabled?: (enabled: boolean) => void;
}
/**
* 凭证卡片组件
*/
const CredentialCard: React.FC<{
credential: CredentialInfo;
onRefresh: () => void;
onDelete: () => void;
}> = ({ credential, onRefresh, onDelete }) => {
const statusColors: Record<string, string> = {
active: "bg-green-500",
inactive: "bg-gray-500",
expired: "bg-yellow-500",
error: "bg-red-500",
};
return (
<Card className="relative">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{credential.displayName || credential.id}
</CardTitle>
<Badge variant="outline" className="text-xs">
<span
className={`w-2 h-2 rounded-full mr-1 ${statusColors[credential.status] || "bg-gray-500"}`}
/>
{credential.status}
</Badge>
</div>
<CardDescription className="text-xs">
认证类型: {credential.authType}
</CardDescription>
</CardHeader>
<CardContent className="pt-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{credential.lastUsedAt
? `最后使用: ${new Date(credential.lastUsedAt).toLocaleDateString()}`
: "未使用"}
</span>
</div>
<div className="flex gap-2 mt-3">
<Button variant="outline" size="sm" onClick={onRefresh}>
<RefreshCw className="h-3 w-3 mr-1" />
刷新
</Button>
<Button variant="destructive" size="sm" onClick={onDelete}>
删除
</Button>
</div>
</CardContent>
</Card>
);
};
// ============================================================================
// 添加凭证模态框
// ============================================================================
type AddCredentialMode = "json" | "file";
interface AddCredentialDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pluginId: string;
pluginName: string;
onAdd: (authType: string, config: Record<string, unknown>) => Promise<void>;
}
const AddCredentialDialog: React.FC<AddCredentialDialogProps> = ({
open,
onOpenChange,
pluginName,
onAdd,
}) => {
const [mode, setMode] = useState<AddCredentialMode>("json");
const [name, setName] = useState("");
const [jsonContent, setJsonContent] = useState("");
const [filePath, setFilePath] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 重置表单
const resetForm = useCallback(() => {
setName("");
setJsonContent("");
setFilePath("");
setError(null);
setLoading(false);
}, []);
// 关闭时重置
const handleClose = useCallback(() => {
resetForm();
onOpenChange(false);
}, [resetForm, onOpenChange]);
// 选择文件
const handleSelectFile = async () => {
try {
const selected = await openFileDialog({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
};
// 提交 JSON 模式
const handleJsonSubmit = async () => {
if (!jsonContent.trim()) {
setError("请粘贴凭证 JSON 内容");
return;
}
// 验证 JSON 格式
let parsedConfig: Record<string, unknown>;
try {
parsedConfig = JSON.parse(jsonContent);
} catch {
setError("JSON 格式无效,请检查内容");
return;
}
setLoading(true);
setError(null);
try {
// 添加显示名称到配置
if (name.trim()) {
parsedConfig.displayName = name.trim();
}
await onAdd("oauth", parsedConfig);
handleClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
// 提交文件模式
const handleFileSubmit = async () => {
if (!filePath) {
setError("请选择凭证文件");
return;
}
setLoading(true);
setError(null);
try {
// 读取文件内容并解析
const config: Record<string, unknown> = {
filePath,
displayName: name.trim() || undefined,
};
await onAdd("oauth", config);
handleClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={open} onClose={handleClose} maxWidth="max-w-md">
{/* Header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<h3 className="text-lg font-semibold">添加 {pluginName} 凭证</h3>
</div>
{/* Content */}
<div className="space-y-4 px-6 py-4">
{/* 模式选择器 */}
<div className="grid grid-cols-2 gap-1 p-1 bg-muted/50 rounded-xl border">
<button
type="button"
onClick={() => {
setMode("json");
setError(null);
}}
disabled={loading}
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
mode === "json"
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<FileText className="inline h-4 w-4 mr-1" />
粘贴 JSON
</button>
<button
type="button"
onClick={() => {
setMode("file");
setError(null);
}}
disabled={loading}
className={`py-2 px-3 text-sm rounded-lg transition-all duration-200 font-medium ${
mode === "file"
? "bg-background text-foreground shadow-sm ring-1 ring-black/5"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<FolderOpen className="inline h-4 w-4 mr-1" />
导入文件
</button>
</div>
{/* 名称字段 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
{/* JSON 模式 */}
{mode === "json" && (
<div>
<label className="mb-1 block text-sm font-medium">
凭证 JSON <span className="text-red-500">*</span>
</label>
<textarea
value={jsonContent}
onChange={(e) => setJsonContent(e.target.value)}
placeholder={`粘贴凭证 JSON 内容,例如:
{
"accessToken": "...",
"refreshToken": "...",
...
}`}
disabled={loading}
className="w-full h-48 rounded-lg border bg-background px-3 py-2 text-sm font-mono resize-none"
/>
</div>
)}
{/* 文件模式 */}
{mode === "file" && (
<div>
<label className="mb-1 block text-sm font-medium">
凭证文件路径 <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="选择凭证文件..."
disabled={loading}
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
/>
<button
type="button"
onClick={handleSelectFile}
disabled={loading}
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
浏览
</button>
</div>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 border-t px-6 py-4">
<button
onClick={handleClose}
disabled={loading}
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
>
取消
</button>
<button
onClick={mode === "json" ? handleJsonSubmit : handleFileSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
</div>
</Modal>
);
};
/**
* OAuth Provider 插件容器
*
* 提供:
* - 凭证列表管理
* - 插件设置
* - 插件 UI 渲染
*/
export const OAuthPluginContainer: React.FC<OAuthPluginContainerProps> = ({
pluginId,
displayName,
description,
version,
enabled = true,
className,
onToggleEnabled,
}) => {
const { sdk, credentials, loading, error, refresh } = usePluginSDK(pluginId);
const [activeTab, setActiveTab] = useState("credentials");
const [addCredentialDialogOpen, setAddCredentialDialogOpen] = useState(false);
// 添加凭证
const handleAddCredential = useCallback(
async (authType: string, config: Record<string, unknown>) => {
try {
await sdk.credential.create(authType, config);
sdk.notification.success("凭证添加成功");
refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : "未知错误";
sdk.notification.error(`添加失败: ${msg}`);
throw e; // 重新抛出以便模态框显示错误
}
},
[sdk, refresh],
);
// 刷新凭证
const handleRefreshCredential = useCallback(
async (credentialId: string) => {
try {
await sdk.credential.refresh(credentialId);
sdk.notification.success("凭证刷新成功");
refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : "未知错误";
sdk.notification.error(`刷新失败: ${msg}`);
}
},
[sdk, refresh],
);
// 删除凭证
const handleDeleteCredential = useCallback(
async (credentialId: string) => {
try {
await sdk.credential.delete(credentialId);
sdk.notification.success("凭证已删除");
refresh();
} catch (e) {
const msg = e instanceof Error ? e.message : "未知错误";
sdk.notification.error(`删除失败: ${msg}`);
}
},
[sdk, refresh],
);
// 加载状态
if (loading) {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">加载插件...</span>
</div>
);
}
// 错误状态
if (error) {
return (
<div
className={`flex flex-col items-center justify-center p-8 ${className}`}
>
<AlertCircle className="h-8 w-8 text-red-500 mb-2" />
<p className="text-red-600 mb-4">{error}</p>
<Button variant="outline" size="sm" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" />
重试
</Button>
</div>
);
}
return (
<div className={`space-y-4 ${className}`}>
{/* 插件头部信息 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
{displayName}
{version && (
<Badge variant="secondary" className="text-xs">
v{version}
</Badge>
)}
</CardTitle>
{description && (
<CardDescription className="mt-1">
{description}
</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant={enabled ? "default" : "secondary"}>
{enabled ? "已启用" : "已禁用"}
</Badge>
{onToggleEnabled && (
<Button
variant="outline"
size="sm"
onClick={() => onToggleEnabled(!enabled)}
>
{enabled ? "禁用" : "启用"}
</Button>
)}
</div>
</div>
</CardHeader>
</Card>
{/* 标签页 */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="credentials" className="flex items-center gap-1">
<Key className="h-4 w-4" />
凭证 ({credentials.length})
</TabsTrigger>
<TabsTrigger value="ui" className="flex items-center gap-1">
插件 UI
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center gap-1">
<Settings2 className="h-4 w-4" />
设置
</TabsTrigger>
</TabsList>
{/* 凭证列表 */}
<TabsContent value="credentials" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium">已配置的凭证</h3>
<Button size="sm" onClick={() => setAddCredentialDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
添加凭证
</Button>
</div>
{credentials.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-muted-foreground border rounded-lg border-dashed">
<Key className="h-8 w-8 mb-2" />
<p>暂无凭证</p>
<p className="text-xs mt-1">点击"添加凭证"开始配置</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setAddCredentialDialogOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
添加第一个凭证
</Button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{credentials.map((cred) => (
<CredentialCard
key={cred.id}
credential={cred}
onRefresh={() => handleRefreshCredential(cred.id)}
onDelete={() => handleDeleteCredential(cred.id)}
/>
))}
</div>
)}
</TabsContent>
{/* 插件 UI */}
<TabsContent value="ui">
<PluginUIContainer
pluginId={pluginId}
emptyMessage="该插件没有提供自定义 UI"
/>
</TabsContent>
{/* 设置 */}
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle className="text-base">插件设置</CardTitle>
<CardDescription>配置插件的行为和参数</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
此插件暂无可配置的设置项
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 添加凭证模态框 */}
<AddCredentialDialog
open={addCredentialDialogOpen}
onOpenChange={setAddCredentialDialogOpen}
pluginId={pluginId}
pluginName={displayName}
onAdd={handleAddCredential}
/>
</div>
);
};
export default OAuthPluginContainer;
+1
View File
@@ -2,5 +2,6 @@ export { PluginManager } from "./PluginManager";
export { PluginInstallDialog } from "./PluginInstallDialog";
export { PluginUninstallDialog } from "./PluginUninstallDialog";
export { PluginUIRenderer } from "./PluginUIRenderer";
export { OAuthPluginContainer } from "./OAuthPluginContainer";
export type { Page } from "./PluginUIRenderer";
export { default } from "./PluginManager";
@@ -0,0 +1,781 @@
/**
* @file OAuth Provider 插件管理标签页
* @description 显示和管理所有 OAuth Provider 插件
* @module components/provider-pool/OAuthPluginTab
*/
import React, { useState, useCallback, useMemo } from "react";
import {
Loader2,
AlertCircle,
RefreshCw,
Plus,
Download,
Search,
Package,
Power,
PowerOff,
Trash2,
ArrowUpCircle,
Cloud,
Sparkles,
Bot,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useOAuthPlugins } from "@/hooks/useOAuthPlugins";
import { PluginUIRenderer } from "@/lib/plugin-loader/PluginUIRenderer";
import { usePluginSDK } from "@/lib/plugin-sdk";
import type {
OAuthPluginInfo,
PluginUpdate,
PluginSource,
} from "@/lib/api/oauthPlugin";
// ============================================================================
// 推荐插件配置
// ============================================================================
/** 推荐插件配置 */
interface RecommendedOAuthPlugin {
/** 插件 ID */
id: string;
/** 插件名称 */
name: string;
/** 插件描述 */
description: string;
/** 图标组件 */
icon: React.ComponentType<{ className?: string }>;
/** 目标协议 */
targetProtocol: string;
/** 安装来源 */
source: PluginSource;
/** 下载 URL(用于一键安装) */
downloadUrl: string;
/** 标签 */
tags?: string[];
}
/**
* 推荐的 OAuth Provider 插件列表
*/
const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
{
id: "kiro-provider",
name: "Kiro (CodeWhisperer)",
description: "Kiro (AWS CodeWhisperer) OAuth Provider - 支持 Claude 模型",
icon: Cloud,
targetProtocol: "anthropic",
source: {
type: "git_hub",
owner: "aiclientproxy",
repo: "kiro-provider",
version: "v0.2.0",
},
downloadUrl:
"https://github.com/aiclientproxy/kiro-provider/releases/download/v0.2.0/kiro-provider-plugin.zip",
tags: ["anthropic", "免费"],
},
{
id: "antigravity-provider",
name: "Antigravity (Gemini CLI)",
description:
"Antigravity (Google Gemini CLI) OAuth Provider - 支持 Gemini 和 Claude 模型",
icon: Sparkles,
targetProtocol: "dynamic",
source: {
type: "git_hub",
owner: "aiclientproxy",
repo: "antigravity-provider",
version: "v0.1.0",
},
downloadUrl:
"https://github.com/aiclientproxy/antigravity-provider/releases/download/v0.1.0/antigravity-provider-plugin.zip",
tags: ["gemini", "claude", "免费"],
},
{
id: "claude-provider",
name: "Claude Provider",
description: "Claude OAuth Provider - 支持 Claude.ai 官方 OAuth 认证",
icon: Bot,
targetProtocol: "anthropic",
source: {
type: "git_hub",
owner: "aiclientproxy",
repo: "claude-provider",
version: "v0.1.0",
},
downloadUrl:
"https://github.com/aiclientproxy/claude-provider/releases/download/v0.1.0/claude-provider-plugin.zip",
tags: ["anthropic", "官方"],
},
{
id: "droid-provider",
name: "Droid Provider",
description:
"Factory.ai Droid OAuth Provider - 支持 Anthropic 和 OpenAI 模型",
icon: Bot,
targetProtocol: "dynamic",
source: {
type: "git_hub",
owner: "aiclientproxy",
repo: "droid-provider",
version: "v0.1.0",
},
downloadUrl:
"https://github.com/aiclientproxy/droid-provider/releases/download/v0.1.0/droid-provider-plugin.zip",
tags: ["anthropic", "openai"],
},
{
id: "gemini-provider",
name: "Gemini Provider",
description: "Google Gemini OAuth Provider - 支持 OAuth 和 API Key 认证",
icon: Sparkles,
targetProtocol: "gemini",
source: {
type: "git_hub",
owner: "aiclientproxy",
repo: "gemini-provider",
version: "v0.1.0",
},
downloadUrl:
"https://github.com/aiclientproxy/gemini-provider/releases/download/v0.1.0/gemini-provider-plugin.zip",
tags: ["gemini", "API Key"],
},
];
// ============================================================================
// 推荐插件卡片组件
// ============================================================================
/**
* 推荐插件卡片组件
*/
const RecommendedPluginCard: React.FC<{
plugin: RecommendedOAuthPlugin;
onInstall: () => void;
installing?: boolean;
}> = ({ plugin, onInstall, installing }) => {
const protocolColors: Record<string, string> = {
anthropic: "bg-orange-500",
openai: "bg-green-500",
gemini: "bg-blue-500",
qwen: "bg-purple-500",
};
return (
<Card className="relative transition-shadow hover:shadow-md">
<Badge
className="absolute -top-2 -right-2 bg-green-500"
variant="default"
>
推荐
</Badge>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<plugin.icon className="h-5 w-5 text-primary" />
</div>
{plugin.name}
</CardTitle>
</div>
<CardDescription className="text-xs line-clamp-2">
{plugin.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant="secondary"
className={`text-xs text-white ${
protocolColors[plugin.targetProtocol.toLowerCase()] ||
"bg-gray-500"
}`}
>
{plugin.targetProtocol}
</Badge>
{plugin.tags?.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</CardContent>
<CardFooter className="pt-2">
<Button className="w-full" onClick={onInstall} disabled={installing}>
{installing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
安装中...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
一键安装
</>
)}
</Button>
</CardFooter>
</Card>
);
};
/**
* 插件卡片组件
*/
const PluginCard: React.FC<{
plugin: OAuthPluginInfo;
update?: PluginUpdate;
onSelect: () => void;
onToggle: () => void;
onUninstall: () => void;
onUpdate?: () => void;
}> = ({ plugin, update, onSelect, onToggle, onUninstall, onUpdate }) => {
const protocolColors: Record<string, string> = {
anthropic: "bg-orange-500",
openai: "bg-green-500",
gemini: "bg-blue-500",
qwen: "bg-purple-500",
};
return (
<Card
className={`relative cursor-pointer transition-shadow hover:shadow-md ${
!plugin.enabled ? "opacity-60" : ""
}`}
onClick={onSelect}
>
{update && (
<Badge
className="absolute -top-2 -right-2 bg-blue-500"
variant="default"
>
有更新
</Badge>
)}
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
{plugin.displayName}
</CardTitle>
<Badge variant="outline" className="text-xs">
v{plugin.version}
</Badge>
</div>
<CardDescription className="text-xs line-clamp-2">
{plugin.description || "无描述"}
</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant="secondary"
className={`text-xs text-white ${
protocolColors[plugin.targetProtocol.toLowerCase()] ||
"bg-gray-500"
}`}
>
{plugin.targetProtocol}
</Badge>
<Badge variant="outline" className="text-xs">
{plugin.credentialCount} 凭证
</Badge>
<Badge
variant={plugin.enabled ? "default" : "secondary"}
className="text-xs"
>
{plugin.enabled ? "已启用" : "已禁用"}
</Badge>
</div>
</CardContent>
<CardFooter className="pt-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
{plugin.enabled ? (
<>
<PowerOff className="h-3 w-3 mr-1" />
禁用
</>
) : (
<>
<Power className="h-3 w-3 mr-1" />
启用
</>
)}
</Button>
{update && onUpdate && (
<Button
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdate();
}}
>
<ArrowUpCircle className="h-3 w-3 mr-1" />
更新
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
onUninstall();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</CardFooter>
</Card>
);
};
// ============================================================================
// 插件详情视图组件
// ============================================================================
/**
* 插件详情视图
* 动态加载并渲染插件自己的 UI 组件
*/
const PluginDetailView: React.FC<{
plugin: OAuthPluginInfo;
onBack: () => void;
onToggle: (enabled: boolean) => void;
}> = ({ plugin, onBack, onToggle }) => {
// 获取插件 SDK
const { sdk } = usePluginSDK(plugin.id);
// 插件目录路径(从配置获取)
const pluginsDir =
plugin.installPath?.replace(`/${plugin.id}`, "") ||
`~/Library/Application Support/proxycast/plugins`;
return (
<div className="space-y-4">
{/* 头部 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={onBack}>
返回列表
</Button>
<div className="flex items-center gap-2">
<Badge variant={plugin.enabled ? "default" : "secondary"}>
{plugin.enabled ? "已启用" : "已禁用"}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => onToggle(!plugin.enabled)}
>
{plugin.enabled ? (
<>
<PowerOff className="h-4 w-4 mr-1" />
禁用
</>
) : (
<>
<Power className="h-4 w-4 mr-1" />
启用
</>
)}
</Button>
</div>
</div>
{/* 插件信息卡片 */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
{plugin.displayName}
</CardTitle>
<Badge variant="outline">v{plugin.version}</Badge>
</div>
<CardDescription>{plugin.description}</CardDescription>
</CardHeader>
</Card>
{/* 插件 UI - 动态加载 */}
<Card>
<CardContent className="pt-6">
<PluginUIRenderer
pluginsDir={pluginsDir}
pluginId={plugin.id}
uiEntry={plugin.uiEntry || "dist/index.js"}
sdk={sdk}
fallback={
<div className="flex flex-col items-center justify-center p-8 text-muted-foreground">
<Package className="h-12 w-12 mb-4 opacity-50" />
<p>该插件没有提供 UI</p>
</div>
}
/>
</CardContent>
</Card>
</div>
);
};
/**
* 安装插件对话框
*/
const InstallPluginDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
onInstall: (path: string) => void;
}> = ({ open, onOpenChange, onInstall }) => {
const [path, setPath] = useState("");
const handleInstall = () => {
if (path.trim()) {
onInstall(path.trim());
setPath("");
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>安装 OAuth Provider 插件</DialogTitle>
<DialogDescription>
输入本地插件目录路径或 GitHub 仓库地址
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">插件路径</label>
<Input
placeholder="例如: /path/to/plugin 或 owner/repo"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleInstall} disabled={!path.trim()}>
<Download className="h-4 w-4 mr-2" />
安装
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
/**
* OAuth Provider 插件管理标签页
*/
export const OAuthPluginTab: React.FC = () => {
const {
plugins,
loading,
error,
updates,
refresh,
enable,
disable,
install,
uninstall,
update,
checkUpdates,
reload,
} = useOAuthPlugins();
const [searchQuery, setSearchQuery] = useState("");
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [uninstallDialogOpen, setUninstallDialogOpen] = useState(false);
const [pluginToUninstall, setPluginToUninstall] = useState<string | null>(
null,
);
const [installingPluginId, setInstallingPluginId] = useState<string | null>(
null,
);
// 已安装插件的 ID 集合
const installedPluginIds = useMemo(
() => new Set(plugins.map((p) => p.id)),
[plugins],
);
// 过滤出未安装的推荐插件
const uninstalledRecommendedPlugins = useMemo(
() => recommendedOAuthPlugins.filter((p) => !installedPluginIds.has(p.id)),
[installedPluginIds],
);
// 过滤插件
const filteredPlugins = plugins.filter(
(p) =>
p.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.targetProtocol.toLowerCase().includes(searchQuery.toLowerCase()),
);
// 处理安装
const handleInstall = useCallback(
async (path: string) => {
const source = path.includes("/")
? { type: "local_file" as const, path }
: { type: "local_file" as const, path };
await install(source);
},
[install],
);
// 处理推荐插件安装
const handleRecommendedInstall = useCallback(
async (plugin: RecommendedOAuthPlugin) => {
setInstallingPluginId(plugin.id);
try {
await install(plugin.source);
} finally {
setInstallingPluginId(null);
}
},
[install],
);
// 处理卸载确认
const handleUninstallConfirm = useCallback(async () => {
if (pluginToUninstall) {
await uninstall(pluginToUninstall);
setPluginToUninstall(null);
setUninstallDialogOpen(false);
if (selectedPluginId === pluginToUninstall) {
setSelectedPluginId(null);
}
}
}, [pluginToUninstall, uninstall, selectedPluginId]);
// 处理切换
const handleToggle = useCallback(
async (plugin: OAuthPluginInfo) => {
if (plugin.enabled) {
await disable(plugin.id);
} else {
await enable(plugin.id);
}
},
[enable, disable],
);
// 获取选中的插件
const selectedPlugin = plugins.find((p) => p.id === selectedPluginId);
// 加载状态
if (loading) {
return (
<div className="flex items-center justify-center p-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">加载插件列表...</span>
</div>
);
}
// 错误状态
if (error) {
return (
<div className="flex flex-col items-center justify-center p-16">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<p className="text-red-600 mb-4">{error}</p>
<Button variant="outline" onClick={refresh}>
<RefreshCw className="h-4 w-4 mr-2" />
重试
</Button>
</div>
);
}
// 显示插件详情
if (selectedPlugin) {
return (
<PluginDetailView
plugin={selectedPlugin}
onBack={() => setSelectedPluginId(null)}
onToggle={(enabled) =>
enabled ? enable(selectedPlugin.id) : disable(selectedPlugin.id)
}
/>
);
}
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索插件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={checkUpdates}>
检查更新
</Button>
<Button variant="outline" size="sm" onClick={reload}>
<RefreshCw className="h-4 w-4 mr-1" />
刷新
</Button>
<Button size="sm" onClick={() => setInstallDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
安装插件
</Button>
</div>
</div>
{/* 更新提示 */}
{updates.length > 0 && (
<Card className="border-blue-200 bg-blue-50">
<CardContent className="py-3">
<div className="flex items-center justify-between">
<span className="text-sm text-blue-700">
有 {updates.length} 个插件可更新
</span>
<Button variant="ghost" size="sm" className="text-blue-700">
查看全部
</Button>
</div>
</CardContent>
</Card>
)}
{/* 推荐插件 */}
{uninstalledRecommendedPlugins.length > 0 && (
<div className="rounded-lg border bg-card">
<div className="p-4 border-b">
<h4 className="font-semibold flex items-center gap-2">
<Download className="h-4 w-4" />
推荐 OAuth Provider 插件
</h4>
<p className="text-sm text-muted-foreground mt-1">
一键安装推荐的 OAuth Provider 插件,快速扩展支持的 AI 服务
</p>
</div>
<div className="p-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{uninstalledRecommendedPlugins.map((plugin) => (
<RecommendedPluginCard
key={plugin.id}
plugin={plugin}
onInstall={() => handleRecommendedInstall(plugin)}
installing={installingPluginId === plugin.id}
/>
))}
</div>
</div>
</div>
)}
{/* 插件列表 */}
{filteredPlugins.length === 0 ? (
<div className="flex flex-col items-center justify-center p-16 border rounded-lg border-dashed">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-2">
{searchQuery ? "没有找到匹配的插件" : "暂无已安装的插件"}
</p>
{!searchQuery && (
<Button
variant="outline"
onClick={() => setInstallDialogOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
安装第一个插件
</Button>
)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPlugins.map((plugin) => (
<PluginCard
key={plugin.id}
plugin={plugin}
update={updates.find((u) => u.pluginId === plugin.id)}
onSelect={() => setSelectedPluginId(plugin.id)}
onToggle={() => handleToggle(plugin)}
onUninstall={() => {
setPluginToUninstall(plugin.id);
setUninstallDialogOpen(true);
}}
onUpdate={
updates.find((u) => u.pluginId === plugin.id)
? () => update(plugin.id)
: undefined
}
/>
))}
</div>
)}
{/* 安装对话框 */}
<InstallPluginDialog
open={installDialogOpen}
onOpenChange={setInstallDialogOpen}
onInstall={handleInstall}
/>
{/* 卸载确认对话框 */}
<Dialog open={uninstallDialogOpen} onOpenChange={setUninstallDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="px-6 pt-6">
<DialogTitle>确认卸载插件</DialogTitle>
<DialogDescription>
此操作将删除插件及其所有凭证数据,此操作无法撤销。
</DialogDescription>
</DialogHeader>
<DialogFooter className="px-6 pb-6 pt-4">
<Button
variant="outline"
onClick={() => setUninstallDialogOpen(false)}
>
取消
</Button>
<Button variant="destructive" onClick={handleUninstallConfirm}>
确认卸载
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default OAuthPluginTab;
@@ -36,6 +36,7 @@ import { VertexAISection } from "./VertexAISection";
import { AmpConfigSection } from "./AmpConfigSection";
import { ProviderIcon } from "@/icons/providers";
import { ApiKeyProviderSection, AddCustomProviderModal } from "./api-key";
import { OAuthPluginTab } from "./OAuthPluginTab";
import type { AddCustomProviderRequest } from "@/lib/api/apiKeyProvider";
import {
getLocalKiroCredentialUuid,
@@ -89,7 +90,7 @@ const isConfigTab = (tab: TabType): tab is ConfigTabType => {
};
// 分类类型
type CategoryType = "oauth" | "apikey" | "config";
type CategoryType = "oauth" | "apikey" | "plugins" | "config";
export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
(_props, ref) => {
@@ -427,6 +428,19 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
>
API Key
</button>
<button
onClick={() => {
setActiveCategory("plugins");
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeCategory === "plugins"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
data-testid="plugins-category-tab"
>
OAuth 插件
</button>
<button
onClick={() => {
setActiveCategory("config");
@@ -518,6 +532,13 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
</div>
)}
{/* OAuth 插件分类 */}
{activeCategory === "plugins" && (
<div className="min-h-[400px]" data-testid="plugins-section">
<OAuthPluginTab />
</div>
)}
{/* 配置 Tab 内容 */}
{activeCategory === "config" &&
isConfigTab(activeTab) &&
@@ -0,0 +1,159 @@
/**
* Antigravity 凭证添加表单(自包含版本)
*
* 这是 AntigravityForm 的包装组件,内部管理所有状态,
* 适合在插件中独立使用。
*
* @module components/provider-pool/credential-forms/AntigravityFormStandalone
*/
import { useState, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { AntigravityForm } from "./AntigravityForm";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
/** Antigravity 凭证文件默认路径 */
const ANTIGRAVITY_DEFAULT_CREDS_PATH = "~/.antigravity/oauth_creds.json";
interface AntigravityFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
}
/**
* 自包含的 Antigravity 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function AntigravityFormStandalone({
onSuccess,
onCancel,
initialName = "",
}: AntigravityFormStandaloneProps) {
const [name, setName] = useState(initialName);
const [credsFilePath, setCredsFilePath] = useState(
ANTIGRAVITY_DEFAULT_CREDS_PATH,
);
const [projectId, setProjectId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
const handleSuccess = useCallback(() => {
onSuccess();
}, [onSuccess]);
// 使用 AntigravityForm hook 获取渲染函数和提交方法
const antigravityForm = AntigravityForm({
name,
credsFilePath,
setCredsFilePath,
projectId,
setProjectId,
onSelectFile: handleSelectFile,
loading,
setLoading,
setError,
onSuccess: handleSuccess,
});
// 处理提交
const handleSubmit = useCallback(() => {
if (antigravityForm.mode === "file") {
antigravityForm.handleFileSubmit();
} else if (antigravityForm.mode === "login") {
antigravityForm.handleGetAuthUrl();
}
}, [antigravityForm]);
// 是否显示提交按钮
const showSubmitButton = antigravityForm.mode === "file";
// login 模式显示获取授权 URL 按钮
const showLoginButton =
antigravityForm.mode === "login" && !antigravityForm.waitingForCallback;
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
{/* Antigravity 表单内容 */}
{antigravityForm.render()}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
{showLoginButton && (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取中...
</>
) : (
"获取授权 URL"
)}
</Button>
)}
{showSubmitButton && (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default AntigravityFormStandalone;
@@ -0,0 +1,141 @@
/**
* Kiro 凭证添加表单(自包含版本)
*
* 这是 KiroForm 的包装组件,内部管理所有状态,
* 适合在插件中独立使用。
*
* @module components/provider-pool/credential-forms/KiroFormStandalone
*/
import { useState, useCallback } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { KiroForm } from "./KiroForm";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
/** Kiro 凭证文件默认路径 */
const KIRO_DEFAULT_CREDS_PATH = "~/.aws/sso/cache/kiro-auth-token.json";
interface KiroFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
}
/**
* 自包含的 Kiro 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function KiroFormStandalone({
onSuccess,
onCancel,
initialName = "",
}: KiroFormStandaloneProps) {
const [name, setName] = useState(initialName);
// 设置默认凭证文件路径
const [credsFilePath, setCredsFilePath] = useState(KIRO_DEFAULT_CREDS_PATH);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectFile = useCallback(async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (selected) {
setCredsFilePath(selected as string);
}
} catch (e) {
console.error("Failed to open file dialog:", e);
}
}, []);
const handleSuccess = useCallback(() => {
onSuccess();
}, [onSuccess]);
// 使用 KiroForm hook 获取渲染函数和提交方法
const kiroForm = KiroForm({
name,
credsFilePath,
setCredsFilePath,
onSelectFile: handleSelectFile,
loading,
setLoading,
setError,
onSuccess: handleSuccess,
});
// 处理提交
const handleSubmit = useCallback(() => {
if (kiroForm.mode === "json") {
kiroForm.handleJsonSubmit();
} else if (kiroForm.mode === "file") {
kiroForm.handleFileSubmit();
}
// login 模式不需要手动提交,登录按钮会直接触发
}, [kiroForm]);
// 是否显示提交按钮(login 模式不需要)
const showSubmitButton = kiroForm.mode !== "login";
return (
<div className="space-y-4">
{/* 名称输入 */}
<div>
<label className="mb-1 block text-sm font-medium">名称 (可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="给这个凭证起个名字..."
disabled={loading}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
{/* Kiro 表单内容 */}
{kiroForm.render()}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* 按钮区域 */}
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
取消
</Button>
)}
{showSubmitButton && (
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default KiroFormStandalone;
+1
View File
@@ -8,3 +8,4 @@ export { CodexSection } from "./CodexSection";
export { IFlowSection } from "./IFlowSection";
export { AmpConfigSection } from "./AmpConfigSection";
export { UsageDisplay } from "./UsageDisplay";
export { OAuthPluginTab } from "./OAuthPluginTab";