feat: 添加 GeminiFormStandalone 组件并更新 gemini-provider 版本到 v0.4.0

- 新增 GeminiFormStandalone 组件支持 OAuth 和 API Key 认证
- 在 plugin-components 中导出 GeminiFormStandalone
- 添加 KeyRound 图标导出
- 更新 OAuthPluginTab 中 gemini-provider 版本配置
- 重构 ProviderModelList 将工具函数移到单独文件
This commit is contained in:
coso
2026-01-06 10:10:05 +08:00
parent 1de6e9a58d
commit a92b626ce1
28 changed files with 2158 additions and 319 deletions
+1 -3
View File
@@ -28,9 +28,7 @@ pub async fn get_model_registry(
/// 刷新模型注册表(从 models.dev 获取最新数据)
#[tauri::command]
pub async fn refresh_model_registry(
state: State<'_, ModelRegistryState>,
) -> Result<(), String> {
pub async fn refresh_model_registry(state: State<'_, ModelRegistryState>) -> Result<(), String> {
let guard = state.read().await;
let service = guard
.as_ref()
@@ -602,8 +602,7 @@ pub fn convert_openai_to_antigravity_with_context(
.parameters
.as_ref()
.map(|p| {
let mut schema =
clean_parameters(Some(p.clone())).unwrap_or_default();
let mut schema = clean_parameters(Some(p.clone())).unwrap_or_default();
// 确保有 type 和 properties
if schema.get("type").is_none() {
schema["type"] = serde_json::json!("object");
@@ -613,9 +612,7 @@ pub fn convert_openai_to_antigravity_with_context(
}
schema
})
.unwrap_or_else(|| {
serde_json::json!({"type": "object", "properties": {}})
});
.unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}}));
if is_claude {
// Claude 模型使用 parameters 字段(标准 Gemini 格式)
+5 -1
View File
@@ -581,7 +581,11 @@ impl CredentialProviderRegistry {
}
}
} else {
info!("No UI assets available for plugin: {} (HTTP {})", plugin_id, ui_response.status());
info!(
"No UI assets available for plugin: {} (HTTP {})",
plugin_id,
ui_response.status()
);
}
} else {
info!("No UI assets available for plugin: {}", plugin_id);
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -598,7 +598,10 @@ mod tests {
#[test]
fn test_model_status_parsing() {
assert_eq!("active".parse::<ModelStatus>().unwrap(), ModelStatus::Active);
assert_eq!(
"active".parse::<ModelStatus>().unwrap(),
ModelStatus::Active
);
assert_eq!(
"deprecated".parse::<ModelStatus>().unwrap(),
ModelStatus::Deprecated
+3 -1
View File
@@ -109,7 +109,9 @@ impl ProviderDefinition {
model_lower.starts_with(parts[0])
} else {
// 复杂模式,回退到简单包含检查
parts.iter().all(|p| p.is_empty() || model_lower.contains(p))
parts
.iter()
.all(|p| p.is_empty() || model_lower.contains(p))
}
} else {
model_lower.contains(&pattern_lower)
@@ -5,8 +5,8 @@
use crate::data::get_local_models;
use crate::database::DbConnection;
use crate::models::model_registry::{
EnhancedModelMetadata, ModelSource, ModelStatus,
ModelSyncState, ModelTier, ModelsDevProvider, UserModelPreference,
EnhancedModelMetadata, ModelSource, ModelStatus, ModelSyncState, ModelTier, ModelsDevProvider,
UserModelPreference,
};
use rusqlite::params;
use std::collections::HashMap;
@@ -135,10 +135,7 @@ impl ModelRegistryService {
let local_models = get_local_models();
let merged = self.merge_models(models_dev_models, local_models);
tracing::info!(
"[ModelRegistry] 获取并合并了 {} 个模型",
merged.len()
);
tracing::info!("[ModelRegistry] 获取并合并了 {} 个模型", merged.len());
// 更新缓存
{
@@ -193,10 +190,7 @@ impl ModelRegistryService {
.map_err(|e| format!("请求 models.dev 失败: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"models.dev 返回错误状态码: {}",
response.status()
));
return Err(format!("models.dev 返回错误状态码: {}", response.status()));
}
let data: HashMap<String, ModelsDevProvider> = response
@@ -283,10 +277,8 @@ impl ModelRegistryService {
provider_name: row.get(3)?,
family: row.get(4)?,
tier: tier_str.parse().unwrap_or(ModelTier::Pro),
capabilities: serde_json::from_str(&capabilities_json)
.unwrap_or_default(),
pricing: pricing_json
.and_then(|s| serde_json::from_str(&s).ok()),
capabilities: serde_json::from_str(&capabilities_json).unwrap_or_default(),
pricing: pricing_json.and_then(|s| serde_json::from_str(&s).ok()),
limits: serde_json::from_str(&limits_json).unwrap_or_default(),
status: status_str.parse().unwrap_or(ModelStatus::Active),
release_date: row.get(10)?,
@@ -361,8 +353,7 @@ impl ModelRegistryService {
.map_err(|e| e.to_string())?;
for model in models {
let capabilities_json =
serde_json::to_string(&model.capabilities).unwrap_or_default();
let capabilities_json = serde_json::to_string(&model.capabilities).unwrap_or_default();
let pricing_json = model
.pricing
.as_ref()
@@ -402,7 +393,11 @@ impl ModelRegistryService {
async fn save_sync_state(&self) -> Result<(), String> {
let (last_sync_at, model_count, last_error) = {
let state = self.sync_state.read().await;
(state.last_sync_at, state.model_count, state.last_error.clone())
(
state.last_sync_at,
state.model_count,
state.last_error.clone(),
)
};
let conn = self.db.lock().map_err(|e| e.to_string())?;
@@ -485,10 +480,7 @@ impl ModelRegistryService {
.collect();
// 按分数降序排序
scored.sort_by(|a, b| {
b.0.partial_cmp(&a.0)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored
.into_iter()
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { Bot, ChevronDown, Check, Box, Settings2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -8,8 +8,47 @@ import {
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Navbar } from "../styles";
import { PROVIDER_CONFIG } from "../types";
import { cn } from "@/lib/utils";
import { useProviderPool } from "@/hooks/useProviderPool";
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
import { useModelRegistry } from "@/hooks/useModelRegistry";
// OAuth 凭证类型到显示名称和 registry ID 的映射
const CREDENTIAL_TYPE_CONFIG: Record<
string,
{ label: string; registryId: string }
> = {
kiro: { label: "Kiro", registryId: "anthropic" },
gemini: { label: "Gemini", registryId: "google" },
qwen: { label: "通义千问", registryId: "alibaba" },
antigravity: { label: "Antigravity", registryId: "google" },
codex: { label: "Codex", registryId: "openai" },
claude_oauth: { label: "Claude OAuth", registryId: "anthropic" },
iflow: { label: "iFlow", registryId: "custom" },
openai: { label: "OpenAI", registryId: "openai" },
claude: { label: "Claude", registryId: "anthropic" },
gemini_api_key: { label: "Gemini", registryId: "google" },
};
// API Key Provider 类型到显示名称和 registry ID 的映射
const API_KEY_PROVIDER_CONFIG: Record<
string,
{ label: string; registryId: string }
> = {
anthropic: { label: "Anthropic", registryId: "anthropic" },
openai: { label: "OpenAI", registryId: "openai" },
gemini: { label: "Gemini", registryId: "google" },
"azure-openai": { label: "Azure OpenAI", registryId: "openai" },
vertexai: { label: "VertexAI", registryId: "google" },
ollama: { label: "Ollama", registryId: "ollama" },
};
/** 已配置的 Provider 信息 */
interface ConfiguredProvider {
key: string;
label: string;
registryId: string;
}
interface ChatNavbarProps {
providerType: string;
@@ -34,9 +73,79 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
}) => {
const [open, setOpen] = useState(false);
const selectedProviderLabel =
PROVIDER_CONFIG[providerType]?.label || providerType;
const currentModels = PROVIDER_CONFIG[providerType]?.models || [];
// 获取凭证池数据
const { overview: oauthCredentials } = useProviderPool();
const { providers: apiKeyProviders } = useApiKeyProvider();
// 获取模型注册表数据
const { models: registryModels } = useModelRegistry({ autoLoad: true });
// 计算已配置的 Provider 列表
const configuredProviders = useMemo(() => {
const providerMap = new Map<string, ConfiguredProvider>();
// 从 OAuth 凭证提取 Provider
oauthCredentials.forEach((overview) => {
if (overview.credentials.length > 0) {
const config = CREDENTIAL_TYPE_CONFIG[overview.provider_type];
if (config && !providerMap.has(overview.provider_type)) {
providerMap.set(overview.provider_type, {
key: overview.provider_type,
label: config.label,
registryId: config.registryId,
});
}
}
});
// 从 API Key Provider 提取(只包含有 API Key 的)
apiKeyProviders
.filter((p) => p.api_key_count > 0 && p.enabled)
.forEach((provider) => {
const config = API_KEY_PROVIDER_CONFIG[provider.type];
if (config && !providerMap.has(provider.type)) {
providerMap.set(provider.type, {
key: provider.type,
label: config.label,
registryId: config.registryId,
});
}
});
return Array.from(providerMap.values());
}, [oauthCredentials, apiKeyProviders]);
// 获取当前选中 Provider 的配置
const selectedProvider = useMemo(() => {
return configuredProviders.find((p) => p.key === providerType);
}, [configuredProviders, providerType]);
// 获取当前 Provider 的模型列表(从 model_registry 获取)
const currentModels = useMemo(() => {
if (!selectedProvider) return [];
// 从 model_registry 获取模型
return registryModels
.filter((m) => m.provider_id === selectedProvider.registryId)
.map((m) => m.id);
}, [selectedProvider, registryModels]);
// 如果当前选中的 Provider 不在已配置列表中,自动切换到第一个已配置的
useEffect(() => {
if (configuredProviders.length > 0 && !selectedProvider) {
const firstProvider = configuredProviders[0];
setProviderType(firstProvider.key);
}
}, [configuredProviders, selectedProvider, setProviderType]);
// 当 Provider 切换时,自动选择第一个模型
useEffect(() => {
if (currentModels.length > 0 && !currentModels.includes(model)) {
setModel(currentModels[0]);
}
}, [currentModels, model, setModel]);
const selectedProviderLabel = selectedProvider?.label || providerType;
return (
<Navbar>
@@ -75,36 +184,36 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
>
{/* Provider/Model Selection */}
<div className="flex h-[300px]">
{/* Left Column: Providers */}
{/* 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>
))}
{configuredProviders.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
暂无已配置的 Provider
</div>
) : (
configuredProviders.map((provider) => (
<button
key={provider.key}
onClick={() => {
setProviderType(provider.key);
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
providerType === provider.key
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
{provider.label}
{providerType === provider.key && (
<div className="w-1 h-1 rounded-full bg-primary" />
)}
</button>
))
)}
</div>
{/* Right Column: Models */}
@@ -141,9 +141,12 @@ export function useAgentChat() {
// 如果不兼容,自动切换到新 provider 的第一个模型
useEffect(() => {
const currentProviderModels = providerConfig[providerType]?.models || [];
if (currentProviderModels.length > 0 && !currentProviderModels.includes(model)) {
if (
currentProviderModels.length > 0 &&
!currentProviderModels.includes(model)
) {
console.log(
`[useAgentChat] 模型 ${model} 不在 ${providerType} 支持列表中,自动切换到 ${currentProviderModels[0]}`
`[useAgentChat] 模型 ${model} 不在 ${providerType} 支持列表中,自动切换到 ${currentProviderModels[0]}`,
);
setModel(currentProviderModels[0]);
}
+1 -6
View File
@@ -9,7 +9,6 @@ import {
} from "lucide-react";
import { LogsTab } from "./LogsTab";
import { RoutesTab } from "./RoutesTab";
import { EnhancedModelsTab } from "./EnhancedModelsTab";
import { ProviderIcon } from "@/icons/providers";
import {
startServer,
@@ -41,7 +40,7 @@ interface TestState {
httpStatus?: number;
}
type TabId = "server" | "routes" | "models" | "logs";
type TabId = "server" | "routes" | "logs";
// 可用的 Provider 信息(合并 OAuth 凭证池和 API Key Provider)
interface AvailableProvider {
@@ -671,7 +670,6 @@ export function ApiServerPage() {
{[
{ id: "server" as TabId, name: "服务器控制" },
{ id: "routes" as TabId, name: "路由端点" },
{ id: "models" as TabId, name: "模型列表" },
{ id: "logs" as TabId, name: "系统日志" },
].map((tab) => (
<button
@@ -1019,9 +1017,6 @@ export function ApiServerPage() {
{/* Routes Tab */}
{activeTab === "routes" && <RoutesTab />}
{/* Models Tab */}
{activeTab === "models" && <EnhancedModelsTab />}
{/* Logs Tab */}
{activeTab === "logs" && <LogsTab />}
</div>
+52 -14
View File
@@ -21,7 +21,10 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import type { EnhancedModelMetadata, ModelTier } from "@/lib/types/modelRegistry";
import type {
EnhancedModelMetadata,
ModelTier,
} from "@/lib/types/modelRegistry";
export function EnhancedModelsTab() {
const {
@@ -113,10 +116,15 @@ export function EnhancedModelsTab() {
"flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors",
showFavoritesOnly
? "bg-yellow-100 border-yellow-300 text-yellow-700 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-300"
: "hover:bg-muted"
: "hover:bg-muted",
)}
>
<Star className={cn("h-4 w-4", showFavoritesOnly && "fill-yellow-500 text-yellow-500")} />
<Star
className={cn(
"h-4 w-4",
showFavoritesOnly && "fill-yellow-500 text-yellow-500",
)}
/>
收藏
</button>
</div>
@@ -127,7 +135,9 @@ export function EnhancedModelsTab() {
onClick={() => setSelectedProvider(null)}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
!selectedProvider ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80"
!selectedProvider
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80",
)}
>
全部 ({models.length})
@@ -138,10 +148,16 @@ export function EnhancedModelsTab() {
return (
<button
key={providerId}
onClick={() => setSelectedProvider(selectedProvider === providerId ? null : providerId)}
onClick={() =>
setSelectedProvider(
selectedProvider === providerId ? null : providerId,
)
}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
selectedProvider === providerId ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80"
selectedProvider === providerId
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80",
)}
>
{providerName} ({providerModels.length})
@@ -157,10 +173,14 @@ export function EnhancedModelsTab() {
{(["mini", "pro", "max"] as ModelTier[]).map((tier) => (
<button
key={tier}
onClick={() => setSelectedTier(selectedTier === tier ? null : tier)}
onClick={() =>
setSelectedTier(selectedTier === tier ? null : tier)
}
className={cn(
"rounded-lg px-3 py-1 text-xs font-medium transition-colors",
selectedTier === tier ? getTierButtonActiveClass(tier) : "bg-muted hover:bg-muted/80"
selectedTier === tier
? getTierButtonActiveClass(tier)
: "bg-muted hover:bg-muted/80",
)}
>
{tier.toUpperCase()}
@@ -174,7 +194,9 @@ export function EnhancedModelsTab() {
<div className="border-b px-4 py-3">
<div className="flex items-center justify-between">
<span className="font-medium">模型列表</span>
<span className="text-sm text-muted-foreground">{filteredModels.length} 个模型</span>
<span className="text-sm text-muted-foreground">
{filteredModels.length} 个模型
</span>
</div>
</div>
@@ -308,7 +330,9 @@ function ModelRow({
<Star
className={cn(
"h-5 w-5",
isFavorite ? "text-yellow-500 fill-yellow-500" : "text-muted-foreground hover:text-yellow-400"
isFavorite
? "text-yellow-500 fill-yellow-500"
: "text-muted-foreground hover:text-yellow-400",
)}
/>
</button>
@@ -336,10 +360,24 @@ function ModelRow({
/** 服务等级徽章 */
function TierBadge({ tier }: { tier: string }) {
const config = {
mini: { label: "Mini", color: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300" },
pro: { label: "Pro", color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" },
max: { label: "Max", color: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" },
}[tier] || { label: tier, color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" };
mini: {
label: "Mini",
color:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
},
pro: {
label: "Pro",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
},
max: {
label: "Max",
color:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
},
}[tier] || {
label: tier,
color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
};
return (
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.color)}>
@@ -61,7 +61,7 @@ export function EnhancedModelList({
}: EnhancedModelListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(["favorites"])
new Set(["favorites"]),
);
// 过滤模型
@@ -74,7 +74,7 @@ export function EnhancedModelList({
m.id.toLowerCase().includes(query) ||
m.display_name.toLowerCase().includes(query) ||
m.provider_name.toLowerCase().includes(query) ||
m.family?.toLowerCase().includes(query)
m.family?.toLowerCase().includes(query),
);
}, [models, searchQuery]);
@@ -130,7 +130,7 @@ export function EnhancedModelList({
<div
className={cn(
"flex items-center justify-center py-8 text-destructive",
className
className,
)}
>
<AlertCircle className="h-5 w-5 mr-2" />
@@ -248,7 +248,7 @@ function ModelItem({
<div
className={cn(
"flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors cursor-pointer",
isSelected && "bg-primary/5"
isSelected && "bg-primary/5",
)}
onClick={onSelect}
>
@@ -257,7 +257,9 @@ function ModelItem({
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center",
isSelected ? "border-primary bg-primary" : "border-muted-foreground"
isSelected
? "border-primary bg-primary"
: "border-muted-foreground",
)}
>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
@@ -313,9 +315,7 @@ function ModelItem({
{showPricing && model.pricing && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<DollarSign className="h-3 w-3" />
<span>
{model.pricing.input_per_million?.toFixed(2) || "?"}
</span>
<span>{model.pricing.input_per_million?.toFixed(2) || "?"}</span>
</div>
)}
@@ -334,7 +334,7 @@ function ModelItem({
"h-4 w-4",
isFavorite
? "text-yellow-500 fill-yellow-500"
: "text-muted-foreground"
: "text-muted-foreground",
)}
/>
</button>
@@ -346,9 +346,20 @@ function ModelItem({
/** 服务等级徽章 */
function TierBadge({ tier }: { tier: string }) {
const config = {
mini: { label: "Mini", color: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300" },
pro: { label: "Pro", color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" },
max: { label: "Max", color: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" },
mini: {
label: "Mini",
color:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
},
pro: {
label: "Pro",
color: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
},
max: {
label: "Max",
color:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
},
}[tier] || { label: tier, color: "bg-gray-100 text-gray-700" };
return (
@@ -361,7 +372,7 @@ function TierBadge({ tier }: { tier: string }) {
/** 获取分组名称 */
function getGroupName(
groupId: string,
firstModel: EnhancedModelMetadata
firstModel: EnhancedModelMetadata,
): string {
if (groupId === "favorites") return "收藏";
if (groupId === "all") return "全部模型";
@@ -0,0 +1,413 @@
/**
* @file ProviderModelSelector 组件
* @description 双栏模型选择器:左侧 Provider 列表,右侧模型列表
* @module components/model-selector/ProviderModelSelector
*/
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import { useProviderPool } from "@/hooks/useProviderPool";
import { useApiKeyProvider } from "@/hooks/useApiKeyProvider";
import {
Check,
ChevronRight,
Eye,
Wrench,
Brain,
Loader2,
AlertCircle,
} from "lucide-react";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
// ============================================================================
// 类型定义
// ============================================================================
export interface ProviderModelSelectorProps {
/** 选择模型回调 */
onSelect?: (model: EnhancedModelMetadata, providerId: string) => void;
/** 初始选中的 Provider */
initialProviderId?: string;
/** 初始选中的模型 */
initialModelId?: string;
/** 自定义类名 */
className?: string;
}
/** 已配置的 Provider 信息 */
interface ConfiguredProvider {
id: string;
name: string;
registryId: string;
source: "oauth" | "apikey";
credentialCount: number;
}
// ============================================================================
// 常量
// ============================================================================
/** OAuth 凭证类型到 Provider ID 的映射 */
const CREDENTIAL_TYPE_TO_PROVIDER_ID: Record<string, string> = {
kiro: "anthropic",
gemini: "google",
qwen: "alibaba",
antigravity: "google",
codex: "openai",
claude_oauth: "anthropic",
iflow: "anthropic",
openai: "openai",
claude: "anthropic",
gemini_api_key: "google",
};
/** API Key Provider 类型到 Registry ID 的映射 */
const PROVIDER_TYPE_TO_REGISTRY_ID: Record<string, string> = {
anthropic: "anthropic",
openai: "openai",
"openai-response": "openai",
gemini: "google",
"azure-openai": "openai",
vertexai: "google",
"aws-bedrock": "anthropic",
ollama: "ollama",
"new-api": "custom",
gateway: "custom",
};
/** Provider 显示名称 */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
google: "Google",
alibaba: "阿里云",
ollama: "Ollama",
custom: "自定义",
};
// ============================================================================
// 子组件
// ============================================================================
interface ProviderItemProps {
provider: ConfiguredProvider;
isSelected: boolean;
onClick: () => void;
}
/** Provider 列表项 */
const ProviderItem: React.FC<ProviderItemProps> = ({
provider,
isSelected,
onClick,
}) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-foreground",
)}
data-testid={`provider-item-${provider.id}`}
>
<div className="flex items-center gap-2 min-w-0">
<ChevronRight
className={cn(
"h-4 w-4 flex-shrink-0 transition-transform",
isSelected && "rotate-90",
)}
/>
<span className="truncate">{provider.name}</span>
</div>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded",
isSelected
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{provider.credentialCount}
</span>
</button>
);
};
interface ModelItemProps {
model: EnhancedModelMetadata;
isSelected: boolean;
onClick: () => void;
}
/** 模型列表项 */
const ModelItem: React.FC<ModelItemProps> = ({
model,
isSelected,
onClick,
}) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
isSelected
? "bg-primary/10 border border-primary"
: "hover:bg-muted border border-transparent",
)}
data-testid={`model-item-${model.id}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{model.display_name}</span>
{model.is_latest && (
<span className="text-[10px] bg-green-100 text-green-700 px-1 py-0.5 rounded">
最新
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">{model.id}</div>
</div>
{/* 能力标签 */}
<div className="flex items-center gap-1.5 ml-2">
{model.capabilities.vision && (
<span title="支持视觉">
<Eye className="h-3.5 w-3.5 text-blue-500" />
</span>
)}
{model.capabilities.tools && (
<span title="支持工具">
<Wrench className="h-3.5 w-3.5 text-orange-500" />
</span>
)}
{model.capabilities.reasoning && (
<span title="支持推理">
<Brain className="h-3.5 w-3.5 text-purple-500" />
</span>
)}
{isSelected && <Check className="h-4 w-4 text-primary ml-1" />}
</div>
</button>
);
};
// ============================================================================
// 主组件
// ============================================================================
/**
* 双栏模型选择器组件
*
* 左侧显示已配置凭证的 Provider 列表(单选)
* 右侧显示选中 Provider 对应的模型列表(单选)
*
* @example
* ```tsx
* <ProviderModelSelector
* onSelect={(model, providerId) => {
* console.log("选中模型:", model.display_name);
* }}
* />
* ```
*/
export const ProviderModelSelector: React.FC<ProviderModelSelectorProps> = ({
onSelect,
initialProviderId,
initialModelId,
className,
}) => {
// 状态
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
initialProviderId || null,
);
const [selectedModelId, setSelectedModelId] = useState<string | null>(
initialModelId || null,
);
// 获取凭证池数据
const { overview: oauthCredentials, loading: oauthLoading } =
useProviderPool();
const { providers: apiKeyProviders, loading: apiKeyLoading } =
useApiKeyProvider();
// 获取模型注册表数据
const {
models,
loading: modelsLoading,
error: modelsError,
} = useModelRegistry({
autoLoad: true,
});
// 计算已配置的 Provider 列表
const configuredProviders = useMemo(() => {
const providerMap = new Map<string, ConfiguredProvider>();
// 从 OAuth 凭证提取 Provider
oauthCredentials.forEach((overview) => {
const registryId = CREDENTIAL_TYPE_TO_PROVIDER_ID[overview.provider_type];
if (registryId && overview.credentials.length > 0) {
const existing = providerMap.get(registryId);
if (existing) {
existing.credentialCount += overview.credentials.length;
} else {
providerMap.set(registryId, {
id: registryId,
name: PROVIDER_DISPLAY_NAMES[registryId] || registryId,
registryId,
source: "oauth",
credentialCount: overview.credentials.length,
});
}
}
});
// 从 API Key Provider 提取(只包含有 API Key 的)
apiKeyProviders
.filter((p) => p.api_key_count > 0 && p.enabled)
.forEach((provider) => {
const registryId =
PROVIDER_TYPE_TO_REGISTRY_ID[provider.type] || provider.type;
const existing = providerMap.get(registryId);
if (existing) {
existing.credentialCount += provider.api_key_count;
} else {
providerMap.set(registryId, {
id: registryId,
name: PROVIDER_DISPLAY_NAMES[registryId] || provider.name,
registryId,
source: "apikey",
credentialCount: provider.api_key_count,
});
}
});
return Array.from(providerMap.values()).sort((a, b) =>
a.name.localeCompare(b.name),
);
}, [oauthCredentials, apiKeyProviders]);
// 默认选中第一个 Provider
useEffect(() => {
if (!selectedProviderId && configuredProviders.length > 0) {
setSelectedProviderId(configuredProviders[0].registryId);
}
}, [selectedProviderId, configuredProviders]);
// 过滤当前 Provider 的模型
const filteredModels = useMemo(() => {
if (!selectedProviderId) return [];
return models.filter((m) => m.provider_id === selectedProviderId);
}, [models, selectedProviderId]);
// 选择 Provider
const handleSelectProvider = useCallback((providerId: string) => {
setSelectedProviderId(providerId);
setSelectedModelId(null); // 切换 Provider 时清除模型选择
}, []);
// 选择模型
const handleSelectModel = useCallback(
(model: EnhancedModelMetadata) => {
setSelectedModelId(model.id);
if (selectedProviderId) {
onSelect?.(model, selectedProviderId);
}
},
[selectedProviderId, onSelect],
);
const isLoading = oauthLoading || apiKeyLoading || modelsLoading;
// 空状态
if (!isLoading && configuredProviders.length === 0) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-12 text-muted-foreground",
className,
)}
data-testid="provider-model-selector-empty"
>
<AlertCircle className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm">暂无已配置的 Provider</p>
<p className="text-xs mt-1">请先在凭证池中添加凭证</p>
</div>
);
}
return (
<div
className={cn("flex border rounded-lg overflow-hidden", className)}
data-testid="provider-model-selector"
>
{/* 左侧:Provider 列表 */}
<div className="w-48 border-r bg-muted/30 flex flex-col">
<div className="px-3 py-2 border-b bg-muted/50">
<h4 className="text-sm font-medium">Providers</h4>
<p className="text-xs text-muted-foreground">已配置凭证的</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
configuredProviders.map((provider) => (
<ProviderItem
key={provider.id}
provider={provider}
isSelected={selectedProviderId === provider.registryId}
onClick={() => handleSelectProvider(provider.registryId)}
/>
))
)}
</div>
</div>
{/* 右侧:模型列表 */}
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b bg-muted/50">
<h4 className="text-sm font-medium">Models</h4>
<p className="text-xs text-muted-foreground">
{selectedProviderId
? `${PROVIDER_DISPLAY_NAMES[selectedProviderId] || selectedProviderId} 的模型`
: "请选择 Provider"}
</p>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{modelsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : modelsError ? (
<div className="flex flex-col items-center justify-center py-8 text-red-500">
<AlertCircle className="h-8 w-8 mb-2" />
<p className="text-sm">{modelsError}</p>
</div>
) : filteredModels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">暂无模型数据</p>
</div>
) : (
filteredModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModelId === model.id}
onClick={() => handleSelectModel(model)}
/>
))
)}
</div>
</div>
</div>
);
};
export default ProviderModelSelector;
+2
View File
@@ -6,6 +6,8 @@ export { ModelSelector } from "./ModelSelector";
export { TierSelector, tierOptions } from "./TierSelector";
export { ModeToggle } from "./ModeToggle";
export { ModelList } from "./ModelList";
export { ProviderModelSelector } from "./ProviderModelSelector";
export type { SelectionMode } from "./ModeToggle";
export type { TierOption } from "./TierSelector";
export type { ProviderModelSelectorProps } from "./ProviderModelSelector";
@@ -0,0 +1,22 @@
/**
* @file ModelRegistryTab 组件
* @description 模型库 Tab,显示所有可用模型
* @module components/provider-pool/ModelRegistryTab
*/
import { EnhancedModelsTab } from "@/components/api-server/EnhancedModelsTab";
/**
* 模型库 Tab 组件
*
* 复用 API Server 的 EnhancedModelsTab 组件
*/
export function ModelRegistryTab() {
return (
<div className="min-h-[400px]" data-testid="model-registry-section">
<EnhancedModelsTab />
</div>
);
}
export default ModelRegistryTab;
@@ -147,10 +147,10 @@ const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
type: "git_hub",
owner: "aiclientproxy",
repo: "droid-provider",
version: "v0.2.0",
version: "v0.3.0",
},
downloadUrl:
"https://github.com/aiclientproxy/droid-provider/releases/download/v0.2.0/droid-provider-plugin.zip",
"https://github.com/aiclientproxy/droid-provider/releases/download/v0.3.0/droid-provider-plugin.zip",
tags: ["anthropic", "openai"],
recommended: false,
available: true,
@@ -165,10 +165,10 @@ const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
type: "git_hub",
owner: "aiclientproxy",
repo: "gemini-provider",
version: "v0.3.0",
version: "v0.4.0",
},
downloadUrl:
"https://github.com/aiclientproxy/gemini-provider/releases/download/v0.3.0/gemini-provider-plugin.zip",
"https://github.com/aiclientproxy/gemini-provider/releases/download/v0.4.0/gemini-provider-plugin.zip",
tags: ["gemini", "API Key"],
recommended: false,
available: true,
@@ -36,6 +36,7 @@ import { ProviderIcon } from "@/icons/providers";
import { ApiKeyProviderSection, AddCustomProviderModal } from "./api-key";
import { OAuthPluginTab } from "./OAuthPluginTab";
import { RelayProvidersSection } from "./RelayProvidersSection";
import { ModelRegistryTab } from "./ModelRegistryTab";
import type { AddCustomProviderRequest } from "@/lib/api/apiKeyProvider";
import {
getLocalKiroCredentialUuid,
@@ -84,7 +85,7 @@ const isConfigTab = (tab: TabType): tab is ConfigTabType => {
};
// 分类类型
type CategoryType = "oauth" | "apikey" | "plugins" | "connect";
type CategoryType = "oauth" | "apikey" | "plugins" | "connect" | "models";
export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
(_props, ref) => {
@@ -410,6 +411,19 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
>
Connect
</button>
<button
onClick={() => {
setActiveCategory("models");
}}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeCategory === "models"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
data-testid="models-category-tab"
>
模型库
</button>
</div>
{/* OAuth 凭证分类 - Provider 选择图标网格 */}
@@ -478,6 +492,9 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
</div>
)}
{/* 模型库分类 */}
{activeCategory === "models" && <ModelRegistryTab />}
{/* OAuth 凭证内容 - 卡片布局 */}
{activeCategory === "oauth" &&
!isConfigTab(activeTab) &&
@@ -7,7 +7,13 @@
* **Validates: Requirements 6.1, 6.2**
*/
import React, { useState, useCallback, useMemo } from "react";
import React, {
useState,
useCallback,
useMemo,
useRef,
useEffect,
} from "react";
import { cn } from "@/lib/utils";
import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/components/Modal";
import { Button } from "@/components/ui/button";
@@ -20,6 +26,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, X } from "lucide-react";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import type { ProviderType } from "@/lib/types/provider";
import type { AddCustomProviderRequest } from "@/lib/api/apiKeyProvider";
@@ -55,6 +63,114 @@ const PROVIDER_TYPE_EXTRA_FIELDS: Record<ProviderType, string[]> = {
gateway: [],
};
/** 已知厂商配置 */
interface KnownProvider {
id: string;
name: string;
type: ProviderType;
apiHost?: string;
}
/** 已知厂商列表(用于快速填充) */
const KNOWN_PROVIDERS: KnownProvider[] = [
{
id: "anthropic",
name: "Anthropic",
type: "anthropic",
apiHost: "https://api.anthropic.com",
},
{
id: "openai",
name: "OpenAI",
type: "openai",
apiHost: "https://api.openai.com",
},
{
id: "google",
name: "Google (Gemini)",
type: "gemini",
apiHost: "https://generativelanguage.googleapis.com",
},
{
id: "alibaba",
name: "阿里云 (通义千问)",
type: "openai",
apiHost: "https://dashscope.aliyuncs.com/compatible-mode",
},
{
id: "deepseek",
name: "DeepSeek",
type: "openai",
apiHost: "https://api.deepseek.com",
},
{
id: "moonshot",
name: "Moonshot (月之暗面)",
type: "openai",
apiHost: "https://api.moonshot.cn",
},
{
id: "zhipu",
name: "智谱 AI",
type: "openai",
apiHost: "https://open.bigmodel.cn/api/paas",
},
{
id: "baichuan",
name: "百川智能",
type: "openai",
apiHost: "https://api.baichuan-ai.com",
},
{
id: "minimax",
name: "MiniMax",
type: "openai",
apiHost: "https://api.minimax.chat",
},
{
id: "groq",
name: "Groq",
type: "openai",
apiHost: "https://api.groq.com/openai",
},
{
id: "together",
name: "Together AI",
type: "openai",
apiHost: "https://api.together.xyz",
},
{
id: "fireworks",
name: "Fireworks AI",
type: "openai",
apiHost: "https://api.fireworks.ai/inference",
},
{
id: "perplexity",
name: "Perplexity",
type: "openai",
apiHost: "https://api.perplexity.ai",
},
{
id: "mistral",
name: "Mistral AI",
type: "openai",
apiHost: "https://api.mistral.ai",
},
{
id: "cohere",
name: "Cohere",
type: "openai",
apiHost: "https://api.cohere.ai",
},
{
id: "ollama",
name: "Ollama (本地)",
type: "ollama",
apiHost: "http://localhost:11434",
},
];
// ============================================================================
// 类型定义
// ============================================================================
@@ -204,6 +320,88 @@ export const AddCustomProviderModal: React.FC<AddCustomProviderModalProps> = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// 厂商搜索状态
const [providerSearch, setProviderSearch] = useState("");
const [showProviderDropdown, setShowProviderDropdown] = useState(false);
const [selectedKnownProvider, setSelectedKnownProvider] =
useState<KnownProvider | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// 从 model_registry 获取额外的 Provider 信息
const { groupedByProvider } = useModelRegistry({ autoLoad: true });
// 合并已知厂商和 model_registry 中的厂商
const allProviders = useMemo(() => {
const providers = [...KNOWN_PROVIDERS];
const existingIds = new Set(providers.map((p) => p.id));
// 从 model_registry 添加额外的厂商
groupedByProvider.forEach((models, providerId) => {
if (!existingIds.has(providerId) && models.length > 0) {
const firstModel = models[0];
providers.push({
id: providerId,
name: firstModel.provider_name,
type: "openai" as ProviderType, // 默认使用 OpenAI 兼容
});
}
});
return providers;
}, [groupedByProvider]);
// 过滤厂商列表
const filteredProviders = useMemo(() => {
if (!providerSearch.trim()) {
return allProviders;
}
const query = providerSearch.toLowerCase();
return allProviders.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.id.toLowerCase().includes(query),
);
}, [allProviders, providerSearch]);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
searchInputRef.current &&
!searchInputRef.current.contains(event.target as Node)
) {
setShowProviderDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// 选择已知厂商
const handleSelectKnownProvider = useCallback((provider: KnownProvider) => {
setSelectedKnownProvider(provider);
setProviderSearch(provider.name);
setShowProviderDropdown(false);
// 自动填充表单
setFormState((prev) => ({
...prev,
name: provider.name,
type: provider.type,
apiHost: provider.apiHost || "",
}));
}, []);
// 清除选中的厂商
const handleClearKnownProvider = useCallback(() => {
setSelectedKnownProvider(null);
setProviderSearch("");
}, []);
// 获取当前类型需要的额外字段
const extraFields = useMemo(
() => PROVIDER_TYPE_EXTRA_FIELDS[formState.type] || [],
@@ -215,6 +413,9 @@ export const AddCustomProviderModal: React.FC<AddCustomProviderModalProps> = ({
setFormState(INITIAL_FORM_STATE);
setErrors({});
setSubmitError(null);
setProviderSearch("");
setSelectedKnownProvider(null);
setShowProviderDropdown(false);
}, []);
// 关闭模态框
@@ -291,6 +492,78 @@ export const AddCustomProviderModal: React.FC<AddCustomProviderModalProps> = ({
<ModalHeader>添加自定义 Provider</ModalHeader>
<ModalBody className="space-y-4">
{/* 搜索厂商(可选) */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">
快速选择厂商{" "}
<span className="text-muted-foreground text-xs">(可选)</span>
</Label>
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
type="text"
value={providerSearch}
onChange={(e) => {
setProviderSearch(e.target.value);
setShowProviderDropdown(true);
if (selectedKnownProvider) {
setSelectedKnownProvider(null);
}
}}
onFocus={() => setShowProviderDropdown(true)}
placeholder="搜索厂商名称..."
disabled={isSubmitting}
className="pl-10 pr-8"
data-testid="provider-search-input"
/>
{(providerSearch || selectedKnownProvider) && (
<button
type="button"
onClick={handleClearKnownProvider}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 下拉列表 */}
{showProviderDropdown && filteredProviders.length > 0 && (
<div
ref={dropdownRef}
className="absolute left-0 right-0 top-full z-[9999] mt-1 max-h-48 overflow-y-auto rounded-md border bg-background shadow-lg"
data-testid="provider-dropdown"
>
{filteredProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleSelectKnownProvider(provider)}
className={cn(
"w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors",
selectedKnownProvider?.id === provider.id && "bg-muted",
)}
>
<div className="font-medium">{provider.name}</div>
{provider.apiHost && (
<div className="text-xs text-muted-foreground truncate">
{provider.apiHost}
</div>
)}
</button>
))}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
选择已知厂商可自动填充配置,或直接手动填写下方表单
</p>
</div>
<div className="border-t border-border" />
{/* Provider 名称 */}
<div className="space-y-1.5">
<Label htmlFor="provider-name" className="text-sm font-medium">
@@ -0,0 +1,209 @@
/**
* @file ProviderModelList 组件
* @description 显示 Provider 支持的模型列表
* @module components/provider-pool/api-key/ProviderModelList
*/
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import { useModelRegistry } from "@/hooks/useModelRegistry";
import { Eye, Wrench, Brain, Sparkles, Loader2 } from "lucide-react";
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
import { mapProviderTypeToRegistryId } from "./providerTypeMapping";
// ============================================================================
// 类型定义
// ============================================================================
export interface ProviderModelListProps {
/** Provider 类型,如 "anthropic", "openai", "gemini" */
providerType: string;
/** 额外的 CSS 类名 */
className?: string;
/** 最大显示数量,默认显示全部 */
maxItems?: number;
}
// ============================================================================
// 子组件
// ============================================================================
interface ModelItemProps {
model: EnhancedModelMetadata;
}
/**
* 单个模型项
*/
const ModelItem: React.FC<ModelItemProps> = ({ model }) => {
return (
<div
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50 transition-colors"
data-testid={`model-item-${model.id}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{model.display_name}
</span>
{model.is_latest && (
<span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded">
最新
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">{model.id}</div>
</div>
{/* 能力标签 */}
<div className="flex items-center gap-1.5 ml-2">
{model.capabilities.vision && (
<span
className="text-blue-500"
title="支持视觉"
data-testid="capability-vision"
>
<Eye className="h-3.5 w-3.5" />
</span>
)}
{model.capabilities.tools && (
<span
className="text-orange-500"
title="支持工具调用"
data-testid="capability-tools"
>
<Wrench className="h-3.5 w-3.5" />
</span>
)}
{model.capabilities.reasoning && (
<span
className="text-purple-500"
title="支持推理"
data-testid="capability-reasoning"
>
<Brain className="h-3.5 w-3.5" />
</span>
)}
</div>
</div>
);
};
// ============================================================================
// 主组件
// ============================================================================
/**
* Provider 支持的模型列表组件
*
* 显示指定 Provider 支持的所有模型,包括模型名称和能力标签
*
* @example
* ```tsx
* <ProviderModelList providerType="anthropic" />
* ```
*/
export const ProviderModelList: React.FC<ProviderModelListProps> = ({
providerType,
className,
maxItems,
}) => {
// 转换 Provider 类型为 registry ID
const registryProviderId = useMemo(
() => mapProviderTypeToRegistryId(providerType),
[providerType],
);
// 获取模型数据
const { models, loading, error } = useModelRegistry({
autoLoad: true,
providerFilter: [registryProviderId],
});
// 限制显示数量
const displayModels = useMemo(() => {
if (maxItems && maxItems > 0) {
return models.slice(0, maxItems);
}
return models;
}, [models, maxItems]);
const hasMore = maxItems && models.length > maxItems;
// 加载状态
if (loading) {
return (
<div
className={cn(
"flex items-center justify-center py-8 text-muted-foreground",
className,
)}
data-testid="provider-model-list-loading"
>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">加载模型列表...</span>
</div>
);
}
// 错误状态
if (error) {
return (
<div
className={cn("py-4 text-center text-sm text-red-500", className)}
data-testid="provider-model-list-error"
>
加载失败: {error}
</div>
);
}
// 空状态
if (models.length === 0) {
return (
<div
className={cn(
"py-4 text-center text-sm text-muted-foreground",
className,
)}
data-testid="provider-model-list-empty"
>
暂无模型数据
</div>
);
}
return (
<div
className={cn("space-y-1", className)}
data-testid="provider-model-list"
>
{/* 标题 */}
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-muted-foreground" />
支持的模型
<span className="text-xs text-muted-foreground font-normal">
({models.length})
</span>
</h4>
</div>
{/* 模型列表 */}
<div className="border rounded-md divide-y divide-border">
{displayModels.map((model) => (
<ModelItem key={model.id} model={model} />
))}
</div>
{/* 显示更多提示 */}
{hasMore && (
<p className="text-xs text-muted-foreground text-center pt-2">
还有 {models.length - maxItems!} 个模型未显示
</p>
)}
</div>
);
};
export default ProviderModelList;
@@ -19,6 +19,7 @@ import {
ConnectionTestButton,
ConnectionTestResult,
} from "./ConnectionTestButton";
import { ProviderModelList } from "./ProviderModelList";
import type {
ProviderWithKeysDisplay,
UpdateProviderRequest,
@@ -231,6 +232,14 @@ export const ProviderSetting: React.FC<ProviderSettingProps> = ({
</p>
)}
</section>
{/* 分隔线 */}
<div className="border-t border-border" />
{/* 支持的模型列表 */}
<section data-testid="supported-models-section">
<ProviderModelList providerType={provider.type} />
</section>
</div>
</div>
);
@@ -45,3 +45,8 @@ export type { DeleteProviderDialogProps } from "./DeleteProviderDialog";
export { ImportExportDialog } from "./ImportExportDialog";
export type { ImportExportDialogProps } from "./ImportExportDialog";
export { ProviderModelList } from "./ProviderModelList";
export type { ProviderModelListProps } from "./ProviderModelList";
export { mapProviderTypeToRegistryId } from "./providerTypeMapping";
@@ -0,0 +1,28 @@
/**
* @file Provider 类型映射工具
* @description Provider 类型到 model_registry provider_id 的映射
* @module components/provider-pool/api-key/providerTypeMapping
*/
/**
* Provider 类型到 model_registry provider_id 的映射
*/
const PROVIDER_TYPE_TO_REGISTRY_ID: Record<string, string> = {
anthropic: "anthropic",
openai: "openai",
"openai-response": "openai",
gemini: "google",
"azure-openai": "openai",
vertexai: "google",
"aws-bedrock": "anthropic",
ollama: "ollama",
"new-api": "custom",
gateway: "custom",
};
/**
* 将 Provider 类型转换为 model_registry 的 provider_id
*/
export function mapProviderTypeToRegistryId(providerType: string): string {
return PROVIDER_TYPE_TO_REGISTRY_ID[providerType] || providerType;
}
@@ -0,0 +1,474 @@
/**
* Gemini 凭证添加表单(自包含版本)
*
* 支持两种认证方式:
* 1. Google OAuth - 使用 Google 账户授权
* 2. API Key - 使用 Google AI Studio API Key
*
* @module components/provider-pool/credential-forms/GeminiFormStandalone
*/
import { useState, useCallback, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import { providerPoolApi } from "@/lib/api/providerPool";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Loader2,
Key,
KeyRound,
Copy,
Check,
ExternalLink,
Upload,
} from "lucide-react";
type AuthMethod = "oauth" | "api_key";
interface GeminiFormStandaloneProps {
/** 添加成功回调 */
onSuccess: () => void;
/** 取消回调 */
onCancel?: () => void;
/** 初始名称 */
initialName?: string;
/** 初始认证方式 */
initialAuthMethod?: AuthMethod;
}
/**
* 自包含的 Gemini 凭证添加表单
*
* 内部管理所有状态,只需要提供 onSuccess 和 onCancel 回调
*/
export function GeminiFormStandalone({
onSuccess,
onCancel,
initialName = "",
initialAuthMethod = "oauth",
}: GeminiFormStandaloneProps) {
const [authMethod, setAuthMethod] = useState<AuthMethod>(initialAuthMethod);
const [name, setName] = useState(initialName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// OAuth 状态
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [authCode, setAuthCode] = useState("");
const [copied, setCopied] = useState(false);
const [exchanging, setExchanging] = useState(false);
// 文件导入状态
const [credsFilePath, setCredsFilePath] = useState("");
const [projectId, setProjectId] = useState("");
// API Key 状态
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
// 监听后端发送的授权 URL 事件
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ auth_url: string; session_id: string }>(
"gemini-auth-url",
(event) => {
console.log("[Gemini OAuth] 收到授权 URL 事件:", event.payload);
setAuthUrl(event.payload.auth_url);
setSessionId(event.payload.session_id);
},
);
};
setupListener();
return () => {
if (unlisten) unlisten();
};
}, []);
// 获取授权 URL
const handleGetAuthUrl = useCallback(async () => {
setLoading(true);
setError(null);
setAuthUrl(null);
setSessionId(null);
setAuthCode("");
try {
await providerPoolApi.getGeminiAuthUrlAndWait(name.trim() || undefined);
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
if (errorMsg.includes("AUTH_URL:")) {
const urlMatch = errorMsg.match(/AUTH_URL:(.+?)(?:\s|$)/);
if (urlMatch) {
setAuthUrl(urlMatch[1]);
}
} else {
setError(errorMsg);
}
} finally {
setLoading(false);
}
}, [name]);
// 用 code 交换 token
const handleExchangeCode = useCallback(async () => {
if (!authCode.trim()) {
setError("请输入授权码");
return;
}
setExchanging(true);
setError(null);
try {
await providerPoolApi.exchangeGeminiCode(
authCode.trim(),
sessionId || undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setExchanging(false);
}
}, [authCode, sessionId, name, onSuccess]);
// 复制 URL
const handleCopyUrl = useCallback(async () => {
if (authUrl) {
await navigator.clipboard.writeText(authUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [authUrl]);
// 选择文件
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 handleFileSubmit = useCallback(async () => {
if (!credsFilePath) {
setError("请选择凭证文件");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addGeminiOAuth(
credsFilePath,
projectId.trim() || undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [credsFilePath, projectId, name, onSuccess]);
// API Key 提交
const handleApiKeySubmit = useCallback(async () => {
if (!apiKey.trim()) {
setError("请输入 API Key");
return;
}
setLoading(true);
setError(null);
try {
await providerPoolApi.addGeminiApiKey(
apiKey.trim(),
baseUrl.trim() || undefined,
undefined,
name.trim() || undefined,
);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [apiKey, baseUrl, name, onSuccess]);
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 || exchanging}
/>
</div>
{/* 认证方式选择 */}
<Tabs
value={authMethod}
onValueChange={(v) => setAuthMethod(v as AuthMethod)}
>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="oauth" className="flex items-center gap-2">
<Key className="h-4 w-4" />
Google OAuth
</TabsTrigger>
<TabsTrigger value="api_key" className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
API Key
</TabsTrigger>
</TabsList>
{/* OAuth 认证 */}
<TabsContent value="oauth" className="space-y-4 mt-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取授权 URL,然后复制到浏览器完成 Google 登录。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,复制页面显示的授权码粘贴到下方输入框。
</p>
</div>
{!authUrl ? (
<Button
onClick={handleGetAuthUrl}
disabled={loading}
className="w-full"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
获取授权 URL...
</>
) : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
获取授权 URL
</>
)}
</Button>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">授权 URL</span>
<button
onClick={handleCopyUrl}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
{copied ? (
<>
<Check className="h-3 w-3" />
已复制
</>
) : (
<>
<Copy className="h-3 w-3" />
复制
</>
)}
</button>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="break-all text-xs text-muted-foreground">
{authUrl.length > 100
? `${authUrl.slice(0, 100)}...`
: authUrl}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
授权码 <span className="text-red-500">*</span>
</label>
<Input
type="text"
value={authCode}
onChange={(e) => setAuthCode(e.target.value)}
placeholder="粘贴浏览器页面显示的授权码..."
/>
<p className="text-xs text-muted-foreground">
在浏览器中完成授权后,复制页面显示的授权码
</p>
</div>
<Button
onClick={handleExchangeCode}
disabled={exchanging || !authCode.trim()}
className="w-full"
>
{exchanging ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
验证中...
</>
) : (
"验证授权码"
)}
</Button>
</div>
)}
{/* 文件导入选项 */}
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground mb-3">
或者导入已有的凭证文件:
</p>
<div className="space-y-3">
<div className="flex gap-2">
<Input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="选择 oauth_creds.json..."
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSelectFile}
>
<Upload className="h-4 w-4" />
</Button>
</div>
<Input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Project ID (可选)"
/>
<Button
onClick={handleFileSubmit}
disabled={loading || !credsFilePath}
variant="outline"
className="w-full"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
导入中...
</>
) : (
"导入凭证文件"
)}
</Button>
</div>
</div>
</TabsContent>
{/* API Key 认证 */}
<TabsContent value="api_key" className="space-y-4 mt-4">
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950/30">
<p className="text-sm text-green-700 dark:text-green-300">
使用 Google AI Studio 的 API Key 进行认证。
</p>
<p className="mt-2 text-xs text-green-600 dark:text-green-400">
从{" "}
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
Google AI Studio
</a>{" "}
获取 API Key。
</p>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium">
API Key <span className="text-red-500">*</span>
</label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="AIzaSy..."
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
Base URL (可选)
</label>
<Input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://generativelanguage.googleapis.com"
/>
<p className="mt-1 text-xs text-muted-foreground">
留空使用官方 API
</p>
</div>
</div>
</TabsContent>
</Tabs>
{/* 错误提示 */}
{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 || exchanging}
>
取消
</Button>
)}
{authMethod === "api_key" && (
<Button
type="button"
onClick={handleApiKeySubmit}
disabled={loading || !apiKey.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
添加中...
</>
) : (
"添加凭证"
)}
</Button>
)}
</div>
</div>
);
}
export default GeminiFormStandalone;
+1
View File
@@ -9,3 +9,4 @@ export { IFlowSection } from "./IFlowSection";
export { AmpConfigSection } from "./AmpConfigSection";
export { UsageDisplay } from "./UsageDisplay";
export { OAuthPluginTab } from "./OAuthPluginTab";
export { ModelRegistryTab } from "./ModelRegistryTab";
+6 -6
View File
@@ -55,7 +55,7 @@ interface UseModelRegistryReturn {
*/
function sortModels(
models: EnhancedModelMetadata[],
preferences: Map<string, UserModelPreference>
preferences: Map<string, UserModelPreference>,
): EnhancedModelMetadata[] {
return [...models].sort((a, b) => {
const prefA = preferences.get(a.id);
@@ -88,7 +88,7 @@ function sortModels(
*/
function fuzzySearch(
models: EnhancedModelMetadata[],
query: string
query: string,
): EnhancedModelMetadata[] {
if (!query.trim()) {
return models;
@@ -140,7 +140,7 @@ function fuzzySearch(
}
export function useModelRegistry(
options: UseModelRegistryOptions = {}
options: UseModelRegistryOptions = {},
): UseModelRegistryReturn {
const {
autoLoad = true,
@@ -206,7 +206,7 @@ export function useModelRegistry(
// 等级过滤
if (tierFilter && tierFilter.length > 0) {
filtered = filtered.filter((m) =>
tierFilter.includes(m.tier as ModelTier)
tierFilter.includes(m.tier as ModelTier),
);
}
@@ -227,7 +227,7 @@ export function useModelRegistry(
(query: string): EnhancedModelMetadata[] => {
return fuzzySearch(models, query);
},
[models]
[models],
);
// 切换收藏
@@ -283,7 +283,7 @@ export function useModelRegistry(
(modelId: string) => {
return allModels.find((m) => m.id === modelId);
},
[allModels]
[allModels],
);
// 按 Provider 分组
+3 -3
View File
@@ -33,7 +33,7 @@ export async function refreshModelRegistry(): Promise<void> {
*/
export async function searchModels(
query: string,
limit?: number
limit?: number,
): Promise<EnhancedModelMetadata[]> {
return invoke("search_models", { query, limit });
}
@@ -82,7 +82,7 @@ export async function getModelSyncState(): Promise<ModelSyncState> {
* @param providerId Provider ID
*/
export async function getModelsForProvider(
providerId: string
providerId: string,
): Promise<EnhancedModelMetadata[]> {
return invoke("get_models_for_provider", { providerId });
}
@@ -92,7 +92,7 @@ export async function getModelsForProvider(
* @param tier 服务等级
*/
export async function getModelsByTier(
tier: ModelTier
tier: ModelTier,
): Promise<EnhancedModelMetadata[]> {
return invoke("get_models_by_tier", { tier });
}
+4
View File
@@ -95,6 +95,9 @@ export { KiroForm } from "@/components/provider-pool/credential-forms/KiroForm";
// Antigravity 凭证表单(自包含版本,适合插件使用)
export { AntigravityFormStandalone } from "@/components/provider-pool/credential-forms/AntigravityFormStandalone";
// Gemini 凭证表单(自包含版本,适合插件使用)
export { GeminiFormStandalone } from "@/components/provider-pool/credential-forms/GeminiFormStandalone";
// 浏览器模式选择器
export {
BrowserModeSelector,
@@ -162,6 +165,7 @@ export {
ExternalLink,
// 凭证相关
Key,
KeyRound,
Lock,
Unlock,
Shield,
+17 -10
View File
@@ -118,23 +118,30 @@ export function PluginUIRenderer({
// 错误
if (error) {
// 检查是否是文件不存在的错误
const isFileNotFound = error.includes("读取插件 UI 文件失败") ||
error.includes("No such file") ||
error.includes("not found") ||
error.includes("没有找到有效的组件导出") ||
error.includes("插件加载失败");
const isFileNotFound =
error.includes("读取插件 UI 文件失败") ||
error.includes("No such file") ||
error.includes("not found") ||
error.includes("没有找到有效的组件导出") ||
error.includes("插件加载失败");
if (isFileNotFound) {
// UI 文件不存在时显示友好提示
return fallback ? <>{fallback}</> : (
<div className={`flex flex-col items-center justify-center p-8 text-muted-foreground ${className}`}>
return fallback ? (
<>{fallback}</>
) : (
<div
className={`flex flex-col items-center justify-center p-8 text-muted-foreground ${className}`}
>
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
<p className="text-center text-sm">该插件暂无 UI 界面</p>
<p className="text-center text-xs mt-1 opacity-70">请通过命令行或 API 使用此插件</p>
<p className="text-center text-xs mt-1 opacity-70">
请通过命令行或 API 使用此插件
</p>
</div>
);
}
return (
<div
className={`flex flex-col items-center justify-center p-8 ${className}`}