mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加 GeminiFormStandalone 组件并更新 gemini-provider 版本到 v0.4.0
- 新增 GeminiFormStandalone 组件支持 OAuth 和 API Key 认证 - 在 plugin-components 中导出 GeminiFormStandalone - 添加 KeyRound 图标导出 - 更新 OAuthPluginTab 中 gemini-provider 版本配置 - 重构 ProviderModelList 将工具函数移到单独文件
This commit is contained in:
@@ -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 格式)
|
||||
|
||||
@@ -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);
|
||||
|
||||
+419
-196
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -9,3 +9,4 @@ export { IFlowSection } from "./IFlowSection";
|
||||
export { AmpConfigSection } from "./AmpConfigSection";
|
||||
export { UsageDisplay } from "./UsageDisplay";
|
||||
export { OAuthPluginTab } from "./OAuthPluginTab";
|
||||
export { ModelRegistryTab } from "./ModelRegistryTab";
|
||||
|
||||
@@ -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 分组
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user