mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-13 01:17:22 +08:00
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -8,3 +8,4 @@ export { CodexSection } from "./CodexSection";
|
||||
export { IFlowSection } from "./IFlowSection";
|
||||
export { AmpConfigSection } from "./AmpConfigSection";
|
||||
export { UsageDisplay } from "./UsageDisplay";
|
||||
export { OAuthPluginTab } from "./OAuthPluginTab";
|
||||
|
||||
Reference in New Issue
Block a user